├── .coveragerc ├── .gitignore ├── .travis.yml ├── AUTHORS ├── COPYING ├── ChangeLog ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile ├── api.rst ├── conf.py ├── examples.rst ├── index.rst └── make.bat ├── examples ├── dial_callback_demo.py ├── dial_polling_demo.py ├── incoming_call_demo.py ├── sms_handler_demo.py └── ussd_demo.py ├── gsmmodem ├── __init__.py ├── compat.py ├── exceptions.py ├── modem.py ├── pdu.py ├── serial_comms.py └── util.py ├── requirements.txt ├── setup.py ├── test ├── __init__.py ├── compat.py ├── fakemodems.py ├── test_gsmterm.py ├── test_modem.py ├── test_pdu.py ├── test_serial_comms.py └── test_util.py └── tools ├── at_cmd_init_modem.txt ├── gsmterm.py ├── gsmtermlib ├── __init__.py ├── atcommands.py ├── posoptparse.py ├── terminal.py └── trie.py ├── identify-modem.py └── sendsms.py /.coveragerc: -------------------------------------------------------------------------------- 1 | # .coveragerc to control coverage.py 2 | [run] 3 | branch = True 4 | source = 5 | gsmmodem/ 6 | tools/gsmtermlib 7 | omit = 8 | # Omit Python 2.6 and 3 compatibility wrappers 9 | gsmmodem/compat.py 10 | tools/gsmtermlib/posoptparse.py 11 | # Omit GSMTerm UI 12 | tools/gsmtermlib/terminal.py 13 | 14 | [report] 15 | # Regexes for lines to exclude from consideration 16 | exclude_lines = 17 | # Have to re-enable the standard pragma 18 | pragma: no cover 19 | # Don't complain about Python version checks and subsequent monkey-patching 20 | if sys.version_info 21 | if PYTHON_VERSION 22 | 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # Package-building stuff 4 | *.egg 5 | *.egg-info 6 | dist 7 | build 8 | docs/_build 9 | 10 | # Eclipse project info 11 | .project 12 | .pydevproject 13 | .settings 14 | # PyCharm project info 15 | .idea 16 | 17 | # Symlinks (if present) 18 | examples/gsmmodem 19 | test/gsmmodem 20 | test/gsmtermlib 21 | tools/gsmmodem 22 | tools/gsmtermlib/gsmmodem 23 | tools/init 24 | 25 | # Unit tests / coverage reports 26 | .coverage 27 | htmlcov 28 | 29 | # Working copy files 30 | *.swp 31 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.3" 4 | - "3.2" 5 | - "2.7" 6 | - "2.6" 7 | install: 8 | # Install unittest2 on Python 2.6 9 | - if [[ $TRAVIS_PYTHON_VERSION == '2.6' ]]; then pip install --use-mirrors unittest2; fi 10 | # Install coveralls (for coveralls.io integration) 11 | - pip install coveralls 12 | - pip install -r requirements.txt --use-mirrors 13 | script: python setup.py coverage 14 | after_success: coveralls 15 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Francois Aucamp 2 | 3 | Thanks to the following people for patches/suggestions: 4 | davidphiliplee 5 | chakphanu 6 | Jonathan Endersby 7 | the01 8 | Frederico Rosmaninho 9 | David Beitey 10 | BOOMER74 11 | 12 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /ChangeLog: -------------------------------------------------------------------------------- 1 | * Thu Jul 18 2013 Francois Aucamp - 0.9 2 | - Added UDH support for SMS PDUs 3 | - Stored messages APIs made public 4 | - USSD support improved on different modem types 5 | - Vastly improved unit test coverage 6 | - Lots of bugfixes and stability improvements 7 | 8 | * Tue May 21 2013 Francois Aucamp - 0.8 9 | - Support added for ZTE modems 10 | - Improved support for Huawei modems 11 | - Outgoing call status can now be tracked via polling (for unknown modems) 12 | - SMSC property added 13 | - Fixes for SMS sending and receiving on different modems 14 | - Added callback mechanism for outoging call status updates 15 | 16 | * Fri Apr 19 2013 Francois Aucamp - 0.7 17 | - Support added for tracking SMS status reports 18 | - PIN unlock support 19 | - SMS API cleaned up 20 | - Bugfixes 21 | 22 | * Tue Apr 03 2013 Francois Aucamp - 0.6 23 | - Added support for PDU mode SMS 24 | - Default SMS read/write mode is now PDU mode 25 | - Added identify-modem.py script to assist with debugging different modem types 26 | - Lots of bugfixes 27 | - Lots of tests added 28 | 29 | * Wed Mar 06 2013 Francois Aucamp - 0.5 30 | - Many bugfixes and improvements, especially to USSD handling 31 | - Improved exceptions to allow more Pythonic error handling 32 | - Tests added for SMS API 33 | - Unit tests speeded up 34 | 35 | * Tue Mar 05 2013 Francois Aucamp - 0.4 36 | - Support added for making voice calls 37 | - Library and utilities now supported under Python 2.6 38 | - Support added for Wavecom modems 39 | - Tests expanded 40 | 41 | * Tue Feb 26 2013 Francois Aucamp - 0.3 42 | - USSD functionality added 43 | - GsmModem class now exposed in main gsmmodem package 44 | - GsmModem test cases added for USSD functionality 45 | - Some fixes to GSMTerm tests 46 | 47 | * Mon Feb 18 2013 Francois Aucamp - 0.2 48 | - Renamed "gsmterm" module to "gsmtermlib" to avoid conflict between startup 49 | script and module 50 | 51 | * Wed Feb 13 2013 Francois Aucamp - 0.1 52 | - Initial 0.1 release to github 53 | - GsmModem class functionality more-or-less where I need it: handles incoming 54 | SMS messages and phone calls, can send SMS messages 55 | - GSMTerm essentially finished 56 | - SendSMS user script needs some polish 57 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS 2 | include ChangeLog 3 | include COPYING 4 | include requirements.txt 5 | include examples/*.py 6 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | python-gsmmodem 2 | =============== 3 | *GSM modem module for Python* 4 | 5 | python-gsmmodem is a module that allows easy control of a GSM modem attached 6 | to the system. It also includes a couple of useful commandline utilities for 7 | interacting with a GSM modem. 8 | 9 | Its features include: 10 | 11 | - simple methods for sending SMS messages, checking signal level, etc 12 | - easy-to-use API for starting and responding to USSD sessions and making voice 13 | calls 14 | - handling incoming phone calls and received SMS messages via callback methods 15 | - support for SMS PDU and text mode 16 | - support for tracking SMS status reports 17 | - wraps AT command errors into Python exceptions by default 18 | - modular design; you easily issue your own AT commands to the modem (with 19 | error checking), or read/write directly from/to the modem if you prefer 20 | - comprehensive test suite 21 | 22 | Bundled utilities: 23 | 24 | - **GSMTerm**: an easy-to-use serial terminal for communicating with an 25 | attached GSM modem. It features command completion, built-in help for many AT 26 | commands, history, context-aware prompt, etc. 27 | - **sendsms.py**: a simple command line script to send SMS messages 28 | - **identify-modem.py**: simple utility to identify attached modem. Can also be 29 | used to provide debug information used for development of python-gsmmodem. 30 | 31 | Requirements 32 | ------------ 33 | 34 | - Python 2.6 or later 35 | - pyserial 36 | 37 | 38 | How to install this package 39 | --------------------------- 40 | 41 | There are two ways to install ``python-gsmmodem``: 42 | 43 | Automatic installation 44 | ~~~~~~~~~~~~~~~~~~~~~~ 45 | 46 | :: 47 | 48 | pip install python-gsmmodem 49 | 50 | `pip `_ will automatically download and install 51 | all dependencies, as required. You can also utilise ``easy_install`` in the 52 | same manner as using ``pip`` above. 53 | 54 | If you are utilising ``python-gsmmodem`` as part of another project, 55 | add it to your ``install_requires`` section of your ``setup.py`` file and 56 | upon your project's installation, it will be pulled in automatically. 57 | 58 | Manual installation 59 | ~~~~~~~~~~~~~~~~~~~ 60 | 61 | Download and extract the ``python-gsmmodem`` archive from `PyPI 62 | `_ for the current release 63 | version, or clone from `GitHub `_. 64 | Next, do this:: 65 | 66 | python setup.py install 67 | 68 | Note that ``python-gsmmodem`` relies on ``pyserial`` for serial communications: 69 | http://pyserial.sourceforge.net 70 | 71 | 72 | Testing the package 73 | ------------------- 74 | 75 | .. |Build Status| image:: https://travis-ci.org/faucamp/python-gsmmodem.png?branch=master 76 | .. _Build Status: https://travis-ci.org/faucamp/python-gsmmodem 77 | 78 | 79 | .. |Coverage Status| image:: https://coveralls.io/repos/faucamp/python-gsmmodem/badge.png?branch=master 80 | .. _Coverage Status: https://coveralls.io/r/faucamp/python-gsmmodem 81 | 82 | |Build Status|_ |Coverage Status|_ 83 | 84 | To run all unit tests, do:: 85 | 86 | python setup.py test 87 | 88 | Unit test code coverage information may be generated by using `coverage 89 | `_. You can execute it directly from 90 | setup.py by doing:: 91 | 92 | python setup.py coverage 93 | 94 | This will run all unit tests and report on code coverage statistics. 95 | 96 | 97 | Building documentation 98 | ---------------------- 99 | 100 | This package contains `Sphinx `_-based documentation. 101 | To manually build or test the documentation locally, do the following:: 102 | 103 | git clone https://github.com/faucamp/python-gsmmodem.git 104 | cd python-gsmmodem 105 | pip install .[doc] 106 | cd doc 107 | make html 108 | 109 | For true isolation, you may wish to run the above commands within a 110 | `virtualenv `_, which will help you manage 111 | this development installation. 112 | 113 | License information 114 | ------------------- 115 | 116 | Copyright (C) 2013 Francois Aucamp 117 | See AUTHORS for all authors and contact information. 118 | 119 | License: GNU Lesser General Public License, version 3 or later; see COPYING 120 | included in this archive for details. 121 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/python-gsmmodem.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/python-gsmmodem.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/python-gsmmodem" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/python-gsmmodem" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API 2 | === 3 | 4 | 5 | GSM Modem 6 | --------- 7 | 8 | .. automodule:: gsmmodem.modem 9 | :members: 10 | 11 | 12 | Serial Communications 13 | --------------------- 14 | 15 | .. automodule:: gsmmodem.serial_comms 16 | :members: 17 | 18 | 19 | PDU 20 | --- 21 | 22 | .. automodule:: gsmmodem.pdu 23 | :members: 24 | 25 | 26 | Utilities 27 | --------- 28 | 29 | .. automodule:: gsmmodem.util 30 | :members: 31 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # python-gsmmodem documentation build configuration file, created by 4 | # sphinx-quickstart on Sun Aug 11 20:50:25 2013. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | #sys.path.insert(0, os.path.abspath('.')) 20 | 21 | # -- General configuration ----------------------------------------------------- 22 | 23 | # If your documentation needs a minimal Sphinx version, state it here. 24 | #needs_sphinx = '1.0' 25 | 26 | # Add any Sphinx extension module names here, as strings. They can be extensions 27 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 28 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.doctest', 'sphinx.ext.viewcode'] 29 | 30 | # Add any paths that contain templates here, relative to this directory. 31 | templates_path = ['_templates'] 32 | 33 | # The suffix of source filenames. 34 | source_suffix = '.rst' 35 | 36 | # The encoding of source files. 37 | #source_encoding = 'utf-8-sig' 38 | 39 | # The master toctree document. 40 | master_doc = 'index' 41 | 42 | # General information about the project. 43 | project = u'python-gsmmodem' 44 | copyright = u'2013, Developers' 45 | 46 | # The version info for the project you're documenting, acts as replacement for 47 | # |version| and |release|, also used in various other places throughout the 48 | # built documents. 49 | # 50 | # The short X.Y version. 51 | version = '0.9' 52 | # The full version, including alpha/beta/rc tags. 53 | release = '0.9' 54 | 55 | # The language for content autogenerated by Sphinx. Refer to documentation 56 | # for a list of supported languages. 57 | #language = None 58 | 59 | # There are two options for replacing |today|: either, you set today to some 60 | # non-false value, then it is used: 61 | #today = '' 62 | # Else, today_fmt is used as the format for a strftime call. 63 | #today_fmt = '%B %d, %Y' 64 | 65 | # List of patterns, relative to source directory, that match files and 66 | # directories to ignore when looking for source files. 67 | exclude_patterns = ['_build'] 68 | 69 | # The reST default role (used for this markup: `text`) to use for all documents. 70 | #default_role = None 71 | 72 | # If true, '()' will be appended to :func: etc. cross-reference text. 73 | #add_function_parentheses = True 74 | 75 | # If true, the current module name will be prepended to all description 76 | # unit titles (such as .. function::). 77 | #add_module_names = True 78 | 79 | # If true, sectionauthor and moduleauthor directives will be shown in the 80 | # output. They are ignored by default. 81 | #show_authors = False 82 | 83 | # The name of the Pygments (syntax highlighting) style to use. 84 | pygments_style = 'sphinx' 85 | 86 | # A list of ignored prefixes for module index sorting. 87 | #modindex_common_prefix = [] 88 | 89 | # If true, keep warnings as "system message" paragraphs in the built documents. 90 | #keep_warnings = False 91 | 92 | 93 | # -- Options for HTML output --------------------------------------------------- 94 | 95 | # The theme to use for HTML and HTML Help pages. See the documentation for 96 | # a list of builtin themes. 97 | html_theme = 'default' 98 | 99 | # Theme options are theme-specific and customize the look and feel of a theme 100 | # further. For a list of options available for each theme, see the 101 | # documentation. 102 | #html_theme_options = {} 103 | 104 | # Add any paths that contain custom themes here, relative to this directory. 105 | #html_theme_path = [] 106 | 107 | # The name for this set of Sphinx documents. If None, it defaults to 108 | # " v documentation". 109 | #html_title = None 110 | 111 | # A shorter title for the navigation bar. Default is the same as html_title. 112 | #html_short_title = None 113 | 114 | # The name of an image file (relative to this directory) to place at the top 115 | # of the sidebar. 116 | #html_logo = None 117 | 118 | # The name of an image file (within the static path) to use as favicon of the 119 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 120 | # pixels large. 121 | #html_favicon = None 122 | 123 | # Add any paths that contain custom static files (such as style sheets) here, 124 | # relative to this directory. They are copied after the builtin static files, 125 | # so a file named "default.css" will overwrite the builtin "default.css". 126 | html_static_path = ['_static'] 127 | 128 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 129 | # using the given strftime format. 130 | #html_last_updated_fmt = '%b %d, %Y' 131 | 132 | # If true, SmartyPants will be used to convert quotes and dashes to 133 | # typographically correct entities. 134 | #html_use_smartypants = True 135 | 136 | # Custom sidebar templates, maps document names to template names. 137 | #html_sidebars = {} 138 | 139 | # Additional templates that should be rendered to pages, maps page names to 140 | # template names. 141 | #html_additional_pages = {} 142 | 143 | # If false, no module index is generated. 144 | #html_domain_indices = True 145 | 146 | # If false, no index is generated. 147 | #html_use_index = True 148 | 149 | # If true, the index is split into individual pages for each letter. 150 | #html_split_index = False 151 | 152 | # If true, links to the reST sources are added to the pages. 153 | #html_show_sourcelink = True 154 | 155 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 156 | #html_show_sphinx = True 157 | 158 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 159 | #html_show_copyright = True 160 | 161 | # If true, an OpenSearch description file will be output, and all pages will 162 | # contain a tag referring to it. The value of this option must be the 163 | # base URL from which the finished HTML is served. 164 | #html_use_opensearch = '' 165 | 166 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 167 | #html_file_suffix = None 168 | 169 | # Output file base name for HTML help builder. 170 | htmlhelp_basename = 'python-gsmmodemdoc' 171 | 172 | 173 | # -- Options for LaTeX output -------------------------------------------------- 174 | 175 | latex_elements = { 176 | # The paper size ('letterpaper' or 'a4paper'). 177 | #'papersize': 'letterpaper', 178 | 179 | # The font size ('10pt', '11pt' or '12pt'). 180 | #'pointsize': '10pt', 181 | 182 | # Additional stuff for the LaTeX preamble. 183 | #'preamble': '', 184 | } 185 | 186 | # Grouping the document tree into LaTeX files. List of tuples 187 | # (source start file, target name, title, author, documentclass [howto/manual]). 188 | latex_documents = [ 189 | ('index', 'python-gsmmodem.tex', u'python-gsmmodem Documentation', 190 | u'Developers', 'manual'), 191 | ] 192 | 193 | # The name of an image file (relative to this directory) to place at the top of 194 | # the title page. 195 | #latex_logo = None 196 | 197 | # For "manual" documents, if this is true, then toplevel headings are parts, 198 | # not chapters. 199 | #latex_use_parts = False 200 | 201 | # If true, show page references after internal links. 202 | #latex_show_pagerefs = False 203 | 204 | # If true, show URL addresses after external links. 205 | #latex_show_urls = False 206 | 207 | # Documents to append as an appendix to all manuals. 208 | #latex_appendices = [] 209 | 210 | # If false, no module index is generated. 211 | #latex_domain_indices = True 212 | 213 | 214 | # -- Options for manual page output -------------------------------------------- 215 | 216 | # One entry per manual page. List of tuples 217 | # (source start file, name, description, authors, manual section). 218 | man_pages = [ 219 | ('index', 'python-gsmmodem', u'python-gsmmodem Documentation', 220 | [u'Developers'], 1) 221 | ] 222 | 223 | # If true, show URL addresses after external links. 224 | #man_show_urls = False 225 | 226 | 227 | # -- Options for Texinfo output ------------------------------------------------ 228 | 229 | # Grouping the document tree into Texinfo files. List of tuples 230 | # (source start file, target name, title, author, 231 | # dir menu entry, description, category) 232 | texinfo_documents = [ 233 | ('index', 'python-gsmmodem', u'python-gsmmodem Documentation', 234 | u'Developers', 'python-gsmmodem', 'One line description of project.', 235 | 'Miscellaneous'), 236 | ] 237 | 238 | # Documents to append as an appendix to all manuals. 239 | #texinfo_appendices = [] 240 | 241 | # If false, no module index is generated. 242 | #texinfo_domain_indices = True 243 | 244 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 245 | #texinfo_show_urls = 'footnote' 246 | 247 | # If true, do not generate a @detailmenu in the "Top" node's menu. 248 | #texinfo_no_detailmenu = False 249 | -------------------------------------------------------------------------------- /docs/examples.rst: -------------------------------------------------------------------------------- 1 | Examples 2 | ======== 3 | 4 | 5 | Dial Callback 6 | ------------- 7 | 8 | .. literalinclude:: ../examples/dial_callback_demo.py 9 | :language: python 10 | 11 | 12 | Dial Polling 13 | ------------ 14 | 15 | .. literalinclude:: ../examples/dial_polling_demo.py 16 | :language: python 17 | 18 | 19 | Incoming Call Handling 20 | ---------------------- 21 | 22 | .. literalinclude:: ../examples/incoming_call_demo.py 23 | :language: python 24 | 25 | 26 | SMS Handling 27 | ------------ 28 | 29 | .. literalinclude:: ../examples/sms_handler_demo.py 30 | :language: python 31 | 32 | 33 | USSD Sessions 34 | ------------- 35 | 36 | .. literalinclude:: ../examples/ussd_demo.py 37 | :language: python 38 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. python-gsmmodem documentation master file, created by 2 | sphinx-quickstart on Sun Aug 11 20:50:25 2013. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to python-gsmmodem's documentation! 7 | =========================================== 8 | 9 | 10 | .. include:: ../README.rst 11 | 12 | .. automodule:: gsmmodem 13 | 14 | 15 | Examples and API 16 | ================ 17 | 18 | .. toctree:: 19 | :maxdepth: 3 20 | 21 | examples.rst 22 | api.rst 23 | 24 | Indices and tables 25 | ================== 26 | 27 | * :ref:`genindex` 28 | * :ref:`modindex` 29 | * :ref:`search` 30 | 31 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.http://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\python-gsmmodem.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\python-gsmmodem.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end 243 | -------------------------------------------------------------------------------- /examples/dial_callback_demo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """\ 4 | Demo: dial a number (using callbacks to track call status) 5 | 6 | Simple demo app that makes a voice call and plays sone DTMF tones (if supported by modem) 7 | when the call is answered, and hangs up the call. 8 | It uses the dial() methods callback mechanism to be informed when the call is answered and ended. 9 | 10 | Note: you need to modify the NUMBER variable for this to work 11 | """ 12 | 13 | from __future__ import print_function 14 | 15 | import sys, time, logging 16 | 17 | PORT = '/dev/ttyUSB2' 18 | BAUDRATE = 115200 19 | NUMBER = '00000' # Number to dial - CHANGE THIS TO A REAL NUMBER 20 | PIN = None # SIM card PIN (if any) 21 | 22 | from gsmmodem.modem import GsmModem 23 | from gsmmodem.exceptions import InterruptedException, CommandError 24 | 25 | waitForCallback = True 26 | 27 | def callStatusCallback(call): 28 | global waitForCallback 29 | print('Call status update callback function called') 30 | if call.answered: 31 | print('Call has been answered; waiting a while...') 32 | # Wait for a bit - some older modems struggle to send DTMF tone immediately after answering a call 33 | time.sleep(3.0) 34 | print('Playing DTMF tones...') 35 | try: 36 | if call.active: # Call could have been ended by remote party while we waited in the time.sleep() call 37 | call.sendDtmfTone('9515999955951') 38 | except InterruptedException as e: 39 | # Call was ended during playback 40 | print('DTMF playback interrupted: {0} ({1} Error {2})'.format(e, e.cause.type, e.cause.code)) 41 | except CommandError as e: 42 | print('DTMF playback failed: {0}'.format(e)) 43 | finally: 44 | if call.active: # Call is still active 45 | print('Hanging up call...') 46 | call.hangup() 47 | waitForCallback = False 48 | else: 49 | # Call is no longer active (remote party ended it) 50 | print('Call has been ended by remote party') 51 | waitForCallback = False 52 | 53 | def main(): 54 | if NUMBER == None or NUMBER == '00000': 55 | print('Error: Please change the NUMBER variable\'s value before running this example.') 56 | sys.exit(1) 57 | print('Initializing modem...') 58 | logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.DEBUG) 59 | modem = GsmModem(PORT, BAUDRATE) 60 | modem.connect(PIN) 61 | print('Waiting for network coverage...') 62 | modem.waitForNetworkCoverage(30) 63 | print('Dialing number: {0}'.format(NUMBER)) 64 | call = modem.dial(NUMBER, callStatusUpdateCallbackFunc=callStatusCallback) 65 | global waitForCallback 66 | while waitForCallback: 67 | time.sleep(0.1) 68 | print('Done') 69 | 70 | if __name__ == '__main__': 71 | main() 72 | -------------------------------------------------------------------------------- /examples/dial_polling_demo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """\ 4 | Demo: dial a number (simple example using polling to check call status) 5 | 6 | Simple demo app that makes a voice call and plays sone DTMF tones (if supported by modem) 7 | when the call is answered, and hangs up the call. 8 | It polls the call status to see if the call has been answered 9 | 10 | Note: you need to modify the NUMBER variable for this to work 11 | """ 12 | 13 | from __future__ import print_function 14 | 15 | import sys, time, logging 16 | 17 | PORT = '/dev/ttyUSB2' 18 | BAUDRATE = 115200 19 | NUMBER = '00000' # Number to dial - CHANGE THIS TO A REAL NUMBER 20 | PIN = None # SIM card PIN (if any) 21 | 22 | from gsmmodem.modem import GsmModem 23 | from gsmmodem.exceptions import InterruptedException, CommandError 24 | 25 | def main(): 26 | if NUMBER == None or NUMBER == '00000': 27 | print('Error: Please change the NUMBER variable\'s value before running this example.') 28 | sys.exit(1) 29 | print('Initializing modem...') 30 | #logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.DEBUG) 31 | modem = GsmModem(PORT, BAUDRATE) 32 | modem.connect(PIN) 33 | print('Waiting for network coverage...') 34 | modem.waitForNetworkCoverage(30) 35 | print('Dialing number: {0}'.format(NUMBER)) 36 | call = modem.dial(NUMBER) 37 | print('Waiting for call to be answered/rejected') 38 | wasAnswered = False 39 | while call.active: 40 | if call.answered: 41 | wasAnswered = True 42 | print('Call has been answered; waiting a while...') 43 | # Wait for a bit - some older modems struggle to send DTMF tone immediately after answering a call 44 | time.sleep(3.0) 45 | print('Playing DTMF tones...') 46 | try: 47 | if call.active: # Call could have been ended by remote party while we waited in the time.sleep() call 48 | call.sendDtmfTone('9515999955951') 49 | except InterruptedException as e: 50 | # Call was ended during playback 51 | print('DTMF playback interrupted: {0} ({1} Error {2})'.format(e, e.cause.type, e.cause.code)) 52 | except CommandError as e: 53 | print('DTMF playback failed: {0}'.format(e)) 54 | finally: 55 | if call.active: # Call is still active 56 | print('Hanging up call...') 57 | call.hangup() 58 | else: # Call is no longer active (remote party ended it) 59 | print('Call has been ended by remote party') 60 | else: 61 | # Wait a bit and check again 62 | time.sleep(0.5) 63 | if not wasAnswered: 64 | print('Call was not answered by remote party') 65 | print('Done.') 66 | modem.close() 67 | 68 | if __name__ == '__main__': 69 | main() 70 | -------------------------------------------------------------------------------- /examples/incoming_call_demo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """\ 4 | Demo: handle incoming calls 5 | 6 | Simple demo app that listens for incoming calls, displays the caller ID, 7 | optionally answers the call and plays sone DTMF tones (if supported by modem), 8 | and hangs up the call. 9 | """ 10 | 11 | from __future__ import print_function 12 | 13 | import time, logging 14 | 15 | PORT = '/dev/ttyUSB2' 16 | BAUDRATE = 115200 17 | PIN = None # SIM card PIN (if any) 18 | 19 | from gsmmodem.modem import GsmModem 20 | from gsmmodem.exceptions import InterruptedException 21 | 22 | def handleIncomingCall(call): 23 | if call.ringCount == 1: 24 | print('Incoming call from:', call.number) 25 | elif call.ringCount >= 2: 26 | if call.dtmfSupport: 27 | print('Answering call and playing some DTMF tones...') 28 | call.answer() 29 | # Wait for a bit - some older modems struggle to send DTMF tone immediately after answering a call 30 | time.sleep(2.0) 31 | try: 32 | call.sendDtmfTone('9515999955951') 33 | except InterruptedException as e: 34 | # Call was ended during playback 35 | print('DTMF playback interrupted: {0} ({1} Error {2})'.format(e, e.cause.type, e.cause.code)) 36 | finally: 37 | if call.answered: 38 | print('Hanging up call.') 39 | call.hangup() 40 | else: 41 | print('Modem has no DTMF support - hanging up call.') 42 | call.hangup() 43 | else: 44 | print(' Call from {0} is still ringing...'.format(call.number)) 45 | 46 | def main(): 47 | print('Initializing modem...') 48 | #logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.DEBUG) 49 | modem = GsmModem(PORT, BAUDRATE, incomingCallCallbackFunc=handleIncomingCall) 50 | modem.connect(PIN) 51 | print('Waiting for incoming calls...') 52 | try: 53 | modem.rxThread.join(2**31) # Specify a (huge) timeout so that it essentially blocks indefinitely, but still receives CTRL+C interrupt signal 54 | finally: 55 | modem.close() 56 | 57 | if __name__ == '__main__': 58 | main() 59 | -------------------------------------------------------------------------------- /examples/sms_handler_demo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """\ 4 | Demo: handle incoming SMS messages by replying to them 5 | 6 | Simple demo app that listens for incoming SMS messages, displays the sender's number 7 | and the messages, then replies to the SMS by saying "thank you" 8 | """ 9 | 10 | from __future__ import print_function 11 | 12 | import logging 13 | 14 | PORT = '/dev/ttyUSB2' 15 | BAUDRATE = 115200 16 | PIN = None # SIM card PIN (if any) 17 | 18 | from gsmmodem.modem import GsmModem 19 | 20 | def handleSms(sms): 21 | print(u'== SMS message received ==\nFrom: {0}\nTime: {1}\nMessage:\n{2}\n'.format(sms.number, sms.time, sms.text)) 22 | print('Replying to SMS...') 23 | sms.reply(u'SMS received: "{0}{1}"'.format(sms.text[:20], '...' if len(sms.text) > 20 else '')) 24 | print('SMS sent.\n') 25 | 26 | def main(): 27 | print('Initializing modem...') 28 | # Uncomment the following line to see what the modem is doing: 29 | logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.DEBUG) 30 | modem = GsmModem(PORT, BAUDRATE, smsReceivedCallbackFunc=handleSms) 31 | modem.smsTextMode = False 32 | modem.connect(PIN) 33 | print('Waiting for SMS message...') 34 | try: 35 | modem.rxThread.join(2**31) # Specify a (huge) timeout so that it essentially blocks indefinitely, but still receives CTRL+C interrupt signal 36 | finally: 37 | modem.close(); 38 | 39 | if __name__ == '__main__': 40 | main() 41 | -------------------------------------------------------------------------------- /examples/ussd_demo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """\ 4 | Demo: Simple USSD example 5 | 6 | Simple demo app that initiates a USSD session, reads the string response and closes the session 7 | (if it wasn't closed by the network) 8 | 9 | Note: for this to work, a valid USSD string for your network must be used. 10 | """ 11 | 12 | from __future__ import print_function 13 | 14 | import logging 15 | 16 | PORT = '/dev/ttyUSB2' 17 | BAUDRATE = 115200 18 | USSD_STRING = '*101#' 19 | PIN = None # SIM card PIN (if any) 20 | 21 | from gsmmodem.modem import GsmModem 22 | 23 | def main(): 24 | print('Initializing modem...') 25 | #logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.DEBUG) 26 | modem = GsmModem(PORT, BAUDRATE) 27 | modem.connect(PIN) 28 | modem.waitForNetworkCoverage(10) 29 | print('Sending USSD string: {0}'.format(USSD_STRING)) 30 | response = modem.sendUssd(USSD_STRING) # response type: gsmmodem.modem.Ussd 31 | print('USSD reply received: {0}'.format(response.message)) 32 | if response.sessionActive: 33 | print('Closing USSD session.') 34 | # At this point, you could also reply to the USSD message by using response.reply() 35 | response.cancel() 36 | else: 37 | print('USSD session was ended by network.') 38 | modem.close() 39 | 40 | if __name__ == '__main__': 41 | main() 42 | -------------------------------------------------------------------------------- /gsmmodem/__init__.py: -------------------------------------------------------------------------------- 1 | """ Package that allows easy control of an attached GSM modem 2 | 3 | The main class for controlling a modem is GsmModem, which can be imported 4 | directly from this module. 5 | 6 | Other important and useful classes are: 7 | gsmmodem.modem.IncomingCall: wraps an incoming call and passed to the incoming call hanndler callback function 8 | gsmmodem.modem.ReceivedSms: wraps a received SMS message and passed to the sms received hanndler callback function 9 | gsmmodem.modem.SentSms: returned when sending SMS messages; used for tracking the status of the SMS message 10 | 11 | All python-gsmmodem-specific exceptions are defined in the gsmmodem.modem.exceptions package. 12 | 13 | @author: Francois Aucamp 14 | @license: LGPLv3+ 15 | """ 16 | 17 | from .modem import GsmModem 18 | -------------------------------------------------------------------------------- /gsmmodem/compat.py: -------------------------------------------------------------------------------- 1 | """ Contains monkey-patched equivalents for a few commonly-used Python 2.7-and-higher functions. 2 | Used to provide backwards-compatibility with Python 2.6 3 | """ 4 | import sys 5 | if sys.version_info[0] == 2 and sys.version_info[1] < 7: 6 | import threading 7 | 8 | # threading.Event.wait() always returns None in Python < 2.7 so we need to patch it 9 | if hasattr(threading, '_Event'): # threading.Event is a function that return threading._Event 10 | # This is heavily Python-implementation-specific, so patch where we can, otherwise leave it 11 | def wrapWait(func): 12 | def newWait(self, timeout=None): 13 | func(self, timeout) 14 | return self.is_set() 15 | return newWait 16 | threading._Event.wait = wrapWait(threading._Event.wait) 17 | else: 18 | raise ImportError('Could not patch this version of Python 2.{0} for compatibility with python-gsmmodem.'.format(sys.version_info[1])) 19 | if sys.version_info[0] == 2: 20 | str = str 21 | else: 22 | str = lambda x: x -------------------------------------------------------------------------------- /gsmmodem/exceptions.py: -------------------------------------------------------------------------------- 1 | """ Module defines exceptions used by gsmmodem """ 2 | 3 | class GsmModemException(Exception): 4 | """ Base exception raised for error conditions when interacting with the GSM modem """ 5 | 6 | 7 | class TimeoutException(GsmModemException): 8 | """ Raised when a write command times out """ 9 | 10 | def __init__(self, data=None): 11 | """ @param data: Any data that was read was read before timeout occurred (if applicable) """ 12 | super(TimeoutException, self).__init__(data) 13 | self.data = data 14 | 15 | 16 | class InvalidStateException(GsmModemException): 17 | """ Raised when an API method call is invoked on an object that is in an incorrect state """ 18 | 19 | 20 | class InterruptedException(InvalidStateException): 21 | """ Raised when execution of an AT command is interrupt by a state change. 22 | May contain another exception that was the cause of the interruption """ 23 | 24 | def __init__(self, message, cause=None): 25 | """ @param cause: the exception that caused this interruption (usually a CmeError) """ 26 | super(InterruptedException, self).__init__(message) 27 | self.cause = cause 28 | 29 | 30 | class CommandError(GsmModemException): 31 | """ Raised if the modem returns an error in response to an AT command 32 | 33 | May optionally include an error type (CME or CMS) and -code (error-specific). 34 | """ 35 | 36 | _description = '' 37 | 38 | def __init__(self, command=None, type=None, code=None): 39 | self.command = command 40 | self.type = type 41 | self.code = code 42 | if type != None and code != None: 43 | super(CommandError, self).__init__('{0} {1}{2}'.format(type, code, ' ({0})'.format(self._description) if len(self._description) > 0 else '')) 44 | elif command != None: 45 | super(CommandError, self).__init__(command) 46 | else: 47 | super(CommandError, self).__init__() 48 | 49 | 50 | class CmeError(CommandError): 51 | """ ME error result code : +CME ERROR: 52 | 53 | Issued in response to an AT command 54 | """ 55 | 56 | def __new__(cls, *args, **kwargs): 57 | # Return a specialized version of this class if possible 58 | if len(args) >= 2: 59 | code = args[1] 60 | if code == 11: 61 | return PinRequiredError(args[0]) 62 | elif code == 16: 63 | return IncorrectPinError(args[0]) 64 | elif code == 12: 65 | return PukRequiredError(args[0]) 66 | return super(CmeError, cls).__new__(cls, *args, **kwargs) 67 | 68 | def __init__(self, command, code): 69 | super(CmeError, self).__init__(command, 'CME', code) 70 | 71 | 72 | class SecurityException(CmeError): 73 | """ Security-related CME error """ 74 | 75 | def __init__(self, command, code): 76 | super(SecurityException, self).__init__(command, code) 77 | 78 | 79 | class PinRequiredError(SecurityException): 80 | """ Raised if an operation failed because the SIM card's PIN has not been entered """ 81 | 82 | _description = 'SIM card PIN is required' 83 | 84 | def __init__(self, command, code=11): 85 | super(PinRequiredError, self).__init__(command, code) 86 | 87 | 88 | class IncorrectPinError(SecurityException): 89 | """ Raised if an incorrect PIN is entered """ 90 | 91 | _description = 'Incorrect PIN entered' 92 | 93 | def __init__(self, command, code=16): 94 | super(IncorrectPinError, self).__init__(command, code) 95 | 96 | 97 | class PukRequiredError(SecurityException): 98 | """ Raised an operation failed because the SIM card's PUK is required (SIM locked) """ 99 | 100 | _description = "PUK required (SIM locked)" 101 | 102 | def __init__(self, command, code=12): 103 | super(PukRequiredError, self).__init__(command, code) 104 | 105 | 106 | class CmsError(CommandError): 107 | """ Message service failure result code: +CMS ERROR : 108 | 109 | Issued in response to an AT command 110 | """ 111 | 112 | def __new__(cls, *args, **kwargs): 113 | # Return a specialized version of this class if possible 114 | if len(args) >= 2: 115 | code = args[1] 116 | if code == 330: 117 | return SmscNumberUnknownError(args[0]) 118 | return super(CmsError, cls).__new__(cls, *args, **kwargs) 119 | 120 | def __init__(self, command, code): 121 | super(CmsError, self).__init__(command, 'CMS', code) 122 | 123 | 124 | class SmscNumberUnknownError(CmsError): 125 | """ Raised if the SMSC (service centre) address is missing when trying to send an SMS message """ 126 | 127 | _description = 'SMSC number not set' 128 | 129 | def __init__(self, command, code=330): 130 | super(SmscNumberUnknownError, self).__init__(command, code) 131 | 132 | 133 | class EncodingError(GsmModemException): 134 | """ Raised if a decoding- or encoding operation failed """ 135 | -------------------------------------------------------------------------------- /gsmmodem/serial_comms.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ Low-level serial communications handling """ 4 | 5 | import sys, threading, logging 6 | 7 | import re 8 | import serial # pyserial: http://pyserial.sourceforge.net 9 | 10 | from .exceptions import TimeoutException 11 | from . import compat # For Python 2.6 compatibility 12 | 13 | class SerialComms(object): 14 | """ Wraps all low-level serial communications (actual read/write operations) """ 15 | 16 | log = logging.getLogger('gsmmodem.serial_comms.SerialComms') 17 | 18 | # End-of-line read terminator 19 | RX_EOL_SEQ = '\r\n' 20 | # End-of-response terminator 21 | RESPONSE_TERM = re.compile(r'^OK|ERROR|(\+CM[ES] ERROR: \d+)|(COMMAND NOT SUPPORT)$') 22 | # Default timeout for serial port reads (in seconds) 23 | timeout = 1 24 | 25 | def __init__(self, port, baudrate=115200, notifyCallbackFunc=None, fatalErrorCallbackFunc=None, *args, **kwargs): 26 | """ Constructor 27 | 28 | :param fatalErrorCallbackFunc: function to call if a fatal error occurs in the serial device reading thread 29 | :type fatalErrorCallbackFunc: func 30 | """ 31 | self.alive = False 32 | self.port = port 33 | self.baudrate = baudrate 34 | 35 | self._responseEvent = None # threading.Event() 36 | self._expectResponseTermSeq = None # expected response terminator sequence 37 | self._response = None # Buffer containing response to a written command 38 | self._notification = [] # Buffer containing lines from an unsolicited notification from the modem 39 | # Reentrant lock for managing concurrent write access to the underlying serial port 40 | self._txLock = threading.RLock() 41 | 42 | self.notifyCallback = notifyCallbackFunc or self._placeholderCallback 43 | self.fatalErrorCallback = fatalErrorCallbackFunc or self._placeholderCallback 44 | 45 | def connect(self): 46 | """ Connects to the device and starts the read thread """ 47 | self.serial = serial.Serial(port=self.port, baudrate=self.baudrate, timeout=self.timeout) 48 | # Start read thread 49 | self.alive = True 50 | self.rxThread = threading.Thread(target=self._readLoop) 51 | self.rxThread.daemon = True 52 | self.rxThread.start() 53 | 54 | def close(self): 55 | """ Stops the read thread, waits for it to exit cleanly, then closes the underlying serial port """ 56 | self.alive = False 57 | self.rxThread.join() 58 | self.serial.close() 59 | 60 | def _handleLineRead(self, line, checkForResponseTerm=True): 61 | #print 'sc.hlineread:',line 62 | if self._responseEvent and not self._responseEvent.is_set(): 63 | # A response event has been set up (another thread is waiting for this response) 64 | self._response.append(line) 65 | if not checkForResponseTerm or self.RESPONSE_TERM.match(line): 66 | # End of response reached; notify waiting thread 67 | #print 'response:', self._response 68 | self.log.debug('response: %s', self._response) 69 | self._responseEvent.set() 70 | else: 71 | # Nothing was waiting for this - treat it as a notification 72 | self._notification.append(line) 73 | if self.serial.inWaiting() == 0: 74 | # No more chars on the way for this notification - notify higher-level callback 75 | #print 'notification:', self._notification 76 | self.log.debug('notification: %s', self._notification) 77 | self.notifyCallback(self._notification) 78 | self._notification = [] 79 | 80 | def _placeholderCallback(self, *args, **kwargs): 81 | """ Placeholder callback function (does nothing) """ 82 | 83 | def _readLoop(self): 84 | """ Read thread main loop 85 | 86 | Reads lines from the connected device 87 | """ 88 | try: 89 | readTermSeq = list(self.RX_EOL_SEQ) 90 | readTermLen = len(readTermSeq) 91 | rxBuffer = [] 92 | while self.alive: 93 | data = self.serial.read(1) 94 | if data != '': # check for timeout 95 | #print >> sys.stderr, ' RX:', data,'({0})'.format(ord(data)) 96 | rxBuffer.append(data) 97 | if rxBuffer[-readTermLen:] == readTermSeq: 98 | # A line (or other logical segment) has been read 99 | line = ''.join(rxBuffer[:-readTermLen]) 100 | rxBuffer = [] 101 | if len(line) > 0: 102 | #print 'calling handler' 103 | self._handleLineRead(line) 104 | elif self._expectResponseTermSeq: 105 | if rxBuffer[-len(self._expectResponseTermSeq):] == self._expectResponseTermSeq: 106 | line = ''.join(rxBuffer) 107 | rxBuffer = [] 108 | self._handleLineRead(line, checkForResponseTerm=False) 109 | #else: 110 | #' ' 111 | except serial.SerialException as e: 112 | self.alive = False 113 | try: 114 | self.serial.close() 115 | except Exception: #pragma: no cover 116 | pass 117 | # Notify the fatal error handler 118 | self.fatalErrorCallback(e) 119 | 120 | def write(self, data, waitForResponse=True, timeout=5, expectedResponseTermSeq=None): 121 | with self._txLock: 122 | if waitForResponse: 123 | if expectedResponseTermSeq: 124 | self._expectResponseTermSeq = list(expectedResponseTermSeq) 125 | self._response = [] 126 | self._responseEvent = threading.Event() 127 | self.serial.write(data) 128 | if self._responseEvent.wait(timeout): 129 | self._responseEvent = None 130 | self._expectResponseTermSeq = False 131 | return self._response 132 | else: # Response timed out 133 | self._responseEvent = None 134 | self._expectResponseTermSeq = False 135 | if len(self._response) > 0: 136 | # Add the partial response to the timeout exception 137 | raise TimeoutException(self._response) 138 | else: 139 | raise TimeoutException() 140 | else: 141 | self.serial.write(data) 142 | -------------------------------------------------------------------------------- /gsmmodem/util.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ Some common utility classes used by tests """ 5 | 6 | from datetime import datetime, timedelta, tzinfo 7 | import re 8 | 9 | class SimpleOffsetTzInfo(tzinfo): 10 | """ Very simple implementation of datetime.tzinfo offering set timezone offset for datetime instances """ 11 | 12 | def __init__(self, offsetInHours=None): 13 | """ Constructs a new tzinfo instance using an amount of hours as an offset 14 | 15 | :param offsetInHours: The timezone offset, in hours (may be negative) 16 | :type offsetInHours: int or float 17 | """ 18 | if offsetInHours != None: #pragma: no cover 19 | self.offsetInHours = offsetInHours 20 | 21 | def utcoffset(self, dt): 22 | return timedelta(hours=self.offsetInHours) 23 | 24 | def dst(self, dt): 25 | return timedelta(0) 26 | 27 | def __repr__(self): 28 | return 'gsmmodem.util.SimpleOffsetTzInfo({0})'.format(self.offsetInHours) 29 | 30 | def parseTextModeTimeStr(timeStr): 31 | """ Parses the specified SMS text mode time string 32 | 33 | The time stamp format is "yy/MM/dd,hh:mm:ss±zz" 34 | (yy = year, MM = month, dd = day, hh = hour, mm = minute, ss = second, zz = time zone 35 | [Note: the unit of time zone is a quarter of an hour]) 36 | 37 | :param timeStr: The time string to parse 38 | :type timeStr: str 39 | 40 | :return: datetime object representing the specified time string 41 | :rtype: datetime.datetime 42 | """ 43 | msgTime = timeStr[:-3] 44 | tzOffsetHours = int(int(timeStr[-3:]) * 0.25) 45 | return datetime.strptime(msgTime, '%y/%m/%d,%H:%M:%S').replace(tzinfo=SimpleOffsetTzInfo(tzOffsetHours)) 46 | 47 | def lineStartingWith(string, lines): 48 | """ Searches through the specified list of strings and returns the 49 | first line starting with the specified search string, or None if not found 50 | """ 51 | for line in lines: 52 | if line.startswith(string): 53 | return line 54 | else: 55 | return None 56 | 57 | def lineMatching(regexStr, lines): 58 | """ Searches through the specified list of strings and returns the regular expression 59 | match for the first line that matches the specified regex string, or None if no match was found 60 | 61 | Note: if you have a pre-compiled regex pattern, use lineMatchingPattern() instead 62 | 63 | :type regexStr: Regular expression string to use 64 | :type lines: List of lines to search 65 | 66 | :return: the regular expression match for the first line that matches the specified regex, or None if no match was found 67 | :rtype: re.Match 68 | """ 69 | regex = re.compile(regexStr) 70 | for line in lines: 71 | m = regex.match(line) 72 | if m: 73 | return m 74 | else: 75 | return None 76 | 77 | def lineMatchingPattern(pattern, lines): 78 | """ Searches through the specified list of strings and returns the regular expression 79 | match for the first line that matches the specified pre-compiled regex pattern, or None if no match was found 80 | 81 | Note: if you are using a regex pattern string (i.e. not already compiled), use lineMatching() instead 82 | 83 | :type pattern: Compiled regular expression pattern to use 84 | :type lines: List of lines to search 85 | 86 | :return: the regular expression match for the first line that matches the specified regex, or None if no match was found 87 | :rtype: re.Match 88 | """ 89 | for line in lines: 90 | m = pattern.match(line) 91 | if m: 92 | return m 93 | else: 94 | return None 95 | 96 | def allLinesMatchingPattern(pattern, lines): 97 | """ Like lineMatchingPattern, but returns all lines that match the specified pattern 98 | 99 | :type pattern: Compiled regular expression pattern to use 100 | :type lines: List of lines to search 101 | 102 | :return: list of re.Match objects for each line matched, or an empty list if none matched 103 | :rtype: list 104 | """ 105 | result = [] 106 | for line in lines: 107 | m = pattern.match(line) 108 | if m: 109 | result.append(m) 110 | return result 111 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyserial>=2.6 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ python-gsmmodem installation script """ 4 | 5 | import sys 6 | from distutils.core import Command 7 | try: 8 | from setuptools import setup 9 | except ImportError: 10 | from distutils.core import setup 11 | 12 | with open('requirements.txt') as f: 13 | requires = f.readlines() 14 | if sys.version_info[0] == 2 and sys.version_info[1] <= 6: 15 | tests_require = ['unittest2'] 16 | test_command = ['unit2', 'discover'] 17 | coverage_command = ['coverage', 'run', '-m', 'unittest2', 'discover'] 18 | else: 19 | tests_require = [] 20 | test_command = [sys.executable, '-m', 'unittest', 'discover'] 21 | coverage_command = ['coverage', 'run', '-m', 'unittest', 'discover'] 22 | 23 | VERSION = 0.9 24 | 25 | class RunUnitTests(Command): 26 | """ run unit tests """ 27 | 28 | user_options = [] 29 | description = __doc__[1:] 30 | 31 | def initialize_options(self): 32 | pass 33 | 34 | def finalize_options(self): 35 | pass 36 | 37 | def run(self): 38 | import subprocess 39 | errno = subprocess.call(test_command) 40 | raise SystemExit(errno) 41 | 42 | class RunUnitTestsCoverage(Command): 43 | """ run unit tests and report on code coverage using the 'coverage' tool """ 44 | 45 | user_options = [] 46 | description = __doc__[1:] 47 | 48 | def initialize_options(self): 49 | pass 50 | 51 | def finalize_options(self): 52 | pass 53 | 54 | def run(self): 55 | import subprocess 56 | errno = subprocess.call(coverage_command) 57 | if errno == 0: 58 | subprocess.call(['coverage', 'report']) 59 | raise SystemExit(errno) 60 | 61 | setup(name='python-gsmmodem', 62 | version='{0}'.format(VERSION), 63 | description='Control an attached GSM modem: send/receive SMS messages, handle calls, etc', 64 | license='LGPLv3+', 65 | author='Francois Aucamp', 66 | author_email='francois.aucamp@gmail.com', 67 | 68 | url='https://github.com/faucamp/python-gsmmodem', 69 | download_url='https://github.com/faucamp/python-gsmmodem/archive/{0}.tar.gz'.format(VERSION), 70 | 71 | long_description="""\ 72 | python-gsmmodem is a module that allows easy control of a GSM modem attached 73 | to the system. It also includes a couple of useful commandline utilities for 74 | interacting with a GSM modem. 75 | 76 | Its features include: 77 | - simple methods for sending SMS messages, checking signal level, etc 78 | - easy-to-use API for starting and responding to USSD sessions and making voice calls 79 | - handling incoming phone calls and received SMS messages via callback methods 80 | - support for SMS PDU and text mode 81 | - support for tracking SMS status reports 82 | - wraps AT command errors into Python exceptions by default 83 | - modular design; you easily issue your own AT commands to the modem with error 84 | (with error checking), or read/write directly from/to the modem if you prefer 85 | - comprehensive test suite 86 | 87 | Bundled utilities: 88 | - GSMTerm: an easy-to-use serial terminal for communicating with an attached GSM 89 | modem. It features command completion, built-in help for many AT commands, 90 | history, context-aware prompt, etc. 91 | - sendsms.py: a simple command line script to send SMS messages 92 | - identify-modem.py: simple utility to identify attached modem. Can also be used to 93 | provide debug information used for development of python-gsmmodem. 94 | """, 95 | 96 | classifiers=['Development Status :: 4 - Beta', 97 | 'Environment :: Console', 98 | 'Intended Audience :: Developers', 99 | 'Intended Audience :: Telecommunications Industry', 100 | 'License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)', 101 | 'Operating System :: OS Independent', 102 | 'Programming Language :: Python :: 2.6', 103 | 'Programming Language :: Python :: 2.7', 104 | 'Programming Language :: Python :: 3', 105 | 'Topic :: Communications :: Telephony', 106 | 'Topic :: Home Automation', 107 | 'Topic :: Software Development :: Libraries :: Python Modules', 108 | 'Topic :: System :: Hardware', 109 | 'Topic :: Terminals :: Serial', 110 | 'Topic :: Utilities'], 111 | keywords = ['gsm', 'sms', 'modem', 'mobile', 'phone', 'usb', 'serial'], 112 | 113 | packages=['gsmmodem', 'gsmtermlib'], 114 | package_dir = {'gsmtermlib': 'tools/gsmtermlib'}, 115 | scripts=['tools/gsmterm.py', 'tools/sendsms.py', 'tools/identify-modem.py'], 116 | install_requires=requires, 117 | tests_require=tests_require, 118 | extras_require={'docs': ['sphinx']}, 119 | cmdclass = {'test': RunUnitTests, 120 | 'coverage': RunUnitTestsCoverage}) 121 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | """ Tests for python-gsmmodem """ 2 | -------------------------------------------------------------------------------- /test/compat.py: -------------------------------------------------------------------------------- 1 | """ Contains equivalents for a few commonly-used Python 2.7-and-higher test functions. 2 | Used to provide backwards-compatibility with Python 2.6 3 | """ 4 | import sys 5 | if sys.version_info[0] == 2 and sys.version_info[1] < 7: 6 | 7 | import unittest 8 | 9 | def assertGreater(self, a, b, msg=None): 10 | """ Drop-in replacement for Python 2.7's method of the same name """ 11 | return self.assertTrue(a > b, msg) 12 | 13 | def assertGreaterEqual(self, a, b, msg=None): 14 | """ Drop-in replacement for Python 2.7's method of the same name """ 15 | return self.assertTrue(a >= b, msg) 16 | 17 | def assertIsInstance(self, a, b, msg=None): 18 | """ Drop-in replacement for Python 2.7's method of the same name """ 19 | return self.assertTrue(isinstance(a, b), msg) 20 | 21 | def assertListEqual(self, a, b, msg=None): 22 | """ Drop-in replacement for Python 2.7's method of the same name """ 23 | if len(a) != len(b): 24 | raise self.failureException(msg or 'List length differs') 25 | else: 26 | for i in xrange(len(a)): 27 | if a[i] != b[i]: 28 | raise self.failureException(msg or 'List differs: {0} != {1}'.format(a[i], b[i])) 29 | 30 | def assertIn(self, a, b, msg=None): 31 | """ Drop-in replacement for Python 2.7's method of the same name """ 32 | return self.assertTrue(a in b, msg) 33 | 34 | def assertNotIn(self, a, b, msg=None): 35 | """ Drop-in replacement for Python 2.7's method of the same name """ 36 | return self.assertTrue(a not in b, msg) 37 | 38 | def assertIs(self, a, b, msg=None): 39 | """ Drop-in replacement for Python 2.7's method of the same name """ 40 | return self.assertTrue(a is b, msg) 41 | 42 | # Monkey-patch our compatibility methods into unittest.TestCase 43 | unittest.TestCase.assertGreater = assertGreater 44 | unittest.TestCase.assertGreaterEqual = assertGreaterEqual 45 | unittest.TestCase.assertIsInstance = assertIsInstance 46 | unittest.TestCase.assertListEqual = assertListEqual 47 | unittest.TestCase.assertIn = assertIn 48 | unittest.TestCase.assertNotIn = assertNotIn 49 | unittest.TestCase.assertIs = assertIs 50 | if sys.version_info[0] == 2: 51 | str = str 52 | bytearrayToStr = str 53 | else: 54 | str = lambda x: x 55 | bytearrayToStr = lambda x: x.decode('latin-1') 56 | -------------------------------------------------------------------------------- /test/fakemodems.py: -------------------------------------------------------------------------------- 1 | """ Module containing fake modem descriptors, for testing """ 2 | 3 | import abc 4 | from copy import copy 5 | 6 | class FakeModem(object): 7 | """ Abstract base class for fake modem descriptors """ 8 | __metaclass__ = abc.ABCMeta 9 | 10 | def __init__(self): 11 | self.responses = {} 12 | self.commandsNoPinRequired = [] 13 | self.commandsSimBusy = [] # Commands that may trigger "SIM busy" errors 14 | self.pinLock = False 15 | self.defaultResponse = ['OK\r\n'] 16 | self.pinRequiredErrorResponse = ['+CME ERROR: 11\r\n'] 17 | self.smscNumber = None 18 | self.simBusyErrorCounter = 0 # Number of times to issue a "SIM busy" error 19 | self.deviceBusyErrorCounter = 0 # Number of times to issue a "Device busy" error 20 | self.cfun = 1 # +CFUN value to report back 21 | self.dtmfCommandBase = '+VTS=' 22 | 23 | def getResponse(self, cmd): 24 | if self.deviceBusyErrorCounter > 0: 25 | self.deviceBusyErrorCounter -= 1 26 | return ['+CME ERROR: 515\r\n'] 27 | if self._pinLock and not cmd.startswith('AT+CPIN'): 28 | if cmd not in self.commandsNoPinRequired: 29 | return copy(self.pinRequiredErrorResponse) 30 | 31 | if cmd.startswith('AT+CPIN="'): 32 | self.pinLock = False 33 | elif self.simBusyErrorCounter > 0 and cmd in self.commandsSimBusy: 34 | self.simBusyErrorCounter -= 1 35 | return ['+CME ERROR: 14\r\n'] 36 | if cmd == 'AT+CFUN?\r' and self.cfun != -1: 37 | return ['+CFUN: {0}\r\n'.format(self.cfun), 'OK\r\n'] 38 | elif cmd == 'AT+CSCA?\r': 39 | if self.smscNumber != None: 40 | return ['+CSCA: "{0}",145\r\n'.format(self.smscNumber), 'OK\r\n'] 41 | else: 42 | return ['OK\r\n'] 43 | if cmd in self.responses: 44 | return copy(self.responses[cmd]) 45 | else: 46 | return copy(self.defaultResponse) 47 | 48 | @property 49 | def pinLock(self): 50 | return self._pinLock 51 | @pinLock.setter 52 | def pinLock(self, pinLock): 53 | self._pinLock = pinLock 54 | if self._pinLock == True: 55 | self.responses['AT+CPIN?\r'] = ['+CPIN: SIM PIN\r\n', 'OK\r\n'] 56 | else: 57 | self.responses['AT+CPIN?\r'] = ['+CPIN: READY\r\n', 'OK\r\n'] 58 | 59 | @abc.abstractmethod 60 | def getAtdResponse(self, number): 61 | return [] 62 | 63 | @abc.abstractmethod 64 | def getPreCallInitWaitSequence(self): 65 | return [0.1] 66 | 67 | @abc.abstractmethod 68 | def getCallInitNotification(self, callId, callType): 69 | return ['+WIND: 5,1\r\n', '+WIND: 2\r\n'] 70 | 71 | @abc.abstractmethod 72 | def getRemoteAnsweredNotification(self, callId, callType): 73 | return ['OK\r\n'] 74 | 75 | @abc.abstractmethod 76 | def getRemoteHangupNotification(self, callId, callType): 77 | return ['NO CARRIER\r\n', '+WIND: 6,1\r\n'] 78 | 79 | def getRemoteRejectCallNotification(self, callId, callType): 80 | # For a lot of modems, this is the same as a hangup notification - override this if necessary! 81 | return self.getRemoteHangupNotification(callId, callType) 82 | 83 | @abc.abstractmethod 84 | def getIncomingCallNotification(self, callerNumber, callType='VOICE', ton=145): 85 | return ['RING\r\n'] 86 | 87 | 88 | class GenericTestModem(FakeModem): 89 | """ Not based on a real modem - simply used for general tests. Uses polling for call status updates """ 90 | 91 | def __init__(self): 92 | super(GenericTestModem, self).__init__() 93 | self._callState = 2 94 | self._callNumber = None 95 | self._callId = None 96 | self.commandsNoPinRequired = ['ATZ\r', 'ATE0\r', 'AT+CFUN?\r', 'AT+CFUN=1\r', 'AT+CMEE=1\r'] 97 | self.responses = {'AT+CPMS=?\r': ['+CPMS: ("ME","MT","SM","SR"),("ME","MT","SM","SR"),("ME","MT","SM","SR")\r\n', 'OK\r\n'], 98 | 'AT+CLAC\r': ['ERROR\r\n'], 99 | 'AT+WIND?\r': ['ERROR\r\n'], 100 | 'AT+WIND=50\r': ['ERROR\r\n'], 101 | 'AT+ZPAS?\r': ['ERROR\r\n'], 102 | 'AT+CPIN?\r': ['+CPIN: READY\r\n', 'OK\r\n']} 103 | 104 | def getResponse(self, cmd): 105 | if not self._pinLock and cmd == 'AT+CLCC\r': 106 | if self._callNumber: 107 | if self._callState == 0: 108 | return ['+CLCC: 1,0,2,0,0,"{0}",129\r\n'.format(self._callNumber), 'OK\r\n'] 109 | elif self._callState == 1: 110 | return ['+CLCC: 1,0,0,0,0,"{0}",129\r\n'.format(self._callNumber), 'OK\r\n'] 111 | else: 112 | return ['OK\r\n'] 113 | else: 114 | return super(GenericTestModem, self).getResponse(cmd) 115 | else: 116 | return super(GenericTestModem, self).getResponse(cmd) 117 | 118 | def getAtdResponse(self, number): 119 | self._callNumber = number 120 | self._callState = 0 121 | return ['OK\r\n'] 122 | 123 | def getPreCallInitWaitSequence(self): 124 | return [0.1] 125 | 126 | def getCallInitNotification(self, callId, callType): 127 | return [] 128 | 129 | def getRemoteAnsweredNotification(self, callId, callType): 130 | self._callState = 1 131 | return [] 132 | 133 | def getRemoteHangupNotification(self, callId, callType): 134 | self._callState = 2 135 | self._callNumber = None 136 | return [] 137 | 138 | def getIncomingCallNotification(self, callerNumber, callType='VOICE', ton=145): 139 | return ['+CRING: {0}\r\n'.format(callType), '+CLIP: "{1}",{2},,,,0\r\n'.format(callType, callerNumber, ton)] 140 | 141 | 142 | class WavecomMultiband900E1800(FakeModem): 143 | """ Family of old Wavecom serial modems 144 | 145 | User franciumlin also submitted the following improvements to this profile: 146 | +CPIN replies are not ended with "OK" 147 | """ 148 | 149 | def __init__(self): 150 | super(WavecomMultiband900E1800, self).__init__() 151 | self.responses = {'AT+CGMI\r': [' WAVECOM MODEM\r\n', 'OK\r\n'], 152 | 'AT+CGMM\r': [' MULTIBAND 900E 1800\r\n', 'OK\r\n'], 153 | 'AT+CGMR\r': ['ERROR\r\n'], 154 | 'AT+CIMI\r': ['111111111111111\r\n', 'OK\r\n'], 155 | 'AT+CGSN\r': ['111111111111111\r\n', 'OK\r\n'], 156 | 'AT+CLAC\r': ['ERROR\r\n'], 157 | 'AT+WIND?\r': ['+WIND: 0\r\n', 'OK\r\n'], 158 | 'AT+WIND=50\r': ['OK\r\n'], 159 | 'AT+ZPAS?\r': ['ERROR\r\n'], 160 | 'AT+CPMS="SM","SM","SR"\r': ['ERROR\r\n'], 161 | 'AT+CPMS=?\r': ['+CPMS: (("SM","BM","SR"),("SM"))\r\n', 'OK\r\n'], 162 | 'AT+CPMS="SM","SM"\r': ['+CPMS: 14,50,14,50\r\n', 'OK\r\n'], 163 | 'AT+CNMI=2,1,0,2\r': ['OK\r\n'], 164 | 'AT+CVHU=0\r': ['ERROR\r\n'], 165 | 'AT+CPIN?\r': ['+CPIN: READY\r\n']} # <---- note: missing 'OK\r\n' 166 | self.commandsNoPinRequired = ['ATZ\r', 'ATE0\r', 'AT+CFUN?\r', 'AT+CFUN=1\r', 'AT+CMEE=1\r'] 167 | 168 | def getResponse(self, cmd): 169 | if cmd == 'AT+CFUN=1\r': 170 | self.deviceBusyErrorCounter = 2 # This modem takes quite a while to recover from this 171 | return ['OK\r\n'] 172 | return super(WavecomMultiband900E1800, self).getResponse(cmd) 173 | 174 | @property 175 | def pinLock(self): 176 | return self._pinLock 177 | @pinLock.setter 178 | def pinLock(self, pinLock): 179 | self._pinLock = pinLock 180 | if self._pinLock == True: 181 | self.responses['AT+CPIN?\r'] = ['+CPIN: SIM PIN\r\n'] # missing OK 182 | else: 183 | self.responses['AT+CPIN?\r'] = ['+CPIN: READY\r\n'] # missing OK 184 | 185 | def getAtdResponse(self, number): 186 | return [] 187 | 188 | def getPreCallInitWaitSequence(self): 189 | return [0.1] 190 | 191 | def getCallInitNotification(self, callId, callType): 192 | # +WIND: 5 == indication of call 193 | # +WIND: 2 == remote party is ringing 194 | return ['+WIND: 5,1\r\n', '+WIND: 2\r\n'] 195 | 196 | def getRemoteAnsweredNotification(self, callId, callType): 197 | return ['OK\r\n'] 198 | 199 | def getRemoteHangupNotification(self, callId, callType): 200 | return ['NO CARRIER\r\n', '+WIND: 6,1\r\n'] 201 | 202 | def getIncomingCallNotification(self, callerNumber, callType='VOICE', ton=145): 203 | return ['+CRING: {0}\r\n'.format(callType), '+CLIP: "{1}",{2}\r\n'.format(callType, callerNumber, ton)] 204 | 205 | def __str__(self): 206 | return 'WAVECOM MODEM MULTIBAND 900E 1800' 207 | 208 | 209 | class HuaweiK3715(FakeModem): 210 | """ Huawei K3715 modem (commonly used by Vodafone) """ 211 | 212 | def __init__(self): 213 | super(HuaweiK3715, self).__init__() 214 | self.responses = {'AT+CGMI\r': ['huawei\r\n', 'OK\r\n'], 215 | 'AT+CGMM\r': ['K3715\r\n', 'OK\r\n'], 216 | 'AT+CGMR\r': ['11.104.05.00.00\r\n', 'OK\r\n'], 217 | 'AT+CIMI\r': ['111111111111111\r\n', 'OK\r\n'], 218 | 'AT+CGSN\r': ['111111111111111\r\n', 'OK\r\n'], 219 | 'AT+CPMS=?\r': ['+CPMS: ("ME","MT","SM","SR"),("ME","MT","SM","SR"),("ME","MT","SM","SR")\r\n', 'OK\r\n'], 220 | 'AT+WIND?\r': ['ERROR\r\n'], 221 | 'AT+WIND=50\r': ['ERROR\r\n'], 222 | 'AT+ZPAS?\r': ['ERROR\r\n'], 223 | 'AT+CLAC\r': ['+CLAC:&C,&D,&E,&F,&S,&V,&W,E,I,L,M,Q,V,X,Z,T,P,\S,\V,\ 224 | %V,D,A,H,O,S0,S2,S3,S4,S5,S6,S7,S8,S9,S10,S11,S30,S103,S104,+FCLASS,+ICF,+IFC,+IPR,+GMI,\ 225 | +GMM,+GMR,+GCAP,+GSN,+DR,+DS,+WS46,+CLAC,+CCLK,+CBST,+CRLP,+CV120,+CHSN,+CSSN,+CREG,+CGREG,\ 226 | +CFUN,+GCAP,+CSCS,+CSTA,+CR,+CEER,+CRC,+CMEE,+CGDCONT,+CGDSCONT,+CGTFT,+CGEQREQ,+CGEQMIN,\ 227 | +CGQREQ,+CGQMIN,+CGEQNEG,+CGEREP,+CGPADDR,+CGCLASS,+CGSMS,+CSMS,+CMGF,+CSAS,+CRES,+CSCA,\ 228 | +CSMP,+CSDH,+CSCB,+FDD,+FAR,+FCL,+FIT,+ES,+ESA,+CMOD,+CVHU,+CGDATA,+CSQ,+CBC,+CPAS,+CPIN,\ 229 | +CMEC,+CGATT,+CGACT,+CGCMOD,+CPBS,+CPBR,+CPBF,+CPBW,+CPMS,+CNMI,+CMGL,+CMGR,+CMGS,+CMSS,\ 230 | +CMGW,+CMGD,+CMGC,+CNMA,+CMMS,+FTS,+FRS,+FTH,+FRH,+FTM,+FRM,+CHUP,+CCFC,+CCUG,+COPS,+CLCK,\ 231 | +CPWD,+CUSD,+CAOC,+CACM,+CAMM,+CPUC,+CCWA,+CHLD,+CIMI,+CGMI,+CGMM,+CGMR,+CGSN,+CNUM,+CSIM,\ 232 | +CRSM,+CCLK,+CLVL,+CMUT,+CLCC,+COPN,+CPOL,+CPLS,+CTZR,+CTZU,+CLAC,+CLIP,+COLP,+CDIP,+CTFR,\ 233 | +CLIR,$QCSIMSTAT,$QCCNMI,$QCCLR,$QCDMG,$QCDMR,$QCDNSP,$QCDNSS,$QCTER,$QCSLOT,$QCPINSTAT,$QCPDPP,\ 234 | $QCPDPLT,$QCPWRDN,$QCDGEN,$BREW,$QCSYSMODE,^CVOICE,^DDSETEX,^pcmrecord,^SYSINFO,^SYSCFG,^IMSICHG,\ 235 | ^HS,^DTMF,^EARST,^CDUR,^LIGHT,^CPBR,^CPBW,^HWVER,^HVER,^DSFLOWCLR,^DSFLOWQRY,^DSFLOWRPT,^SPN,\ 236 | ^PORTSEL,^CPIN,^PNN,^OPL,^CPNN,^SN,^CARDLOCK,^BOOT,^FHVER,^CURC,^FREQLOCK,^HSDPA,^HSUPA,^CARDMODE,\ 237 | ^U2DIAG,^CELLMODE,^HSPA,^SCSIOVERTIME,^SETPID,^ADCTEMP,^OPWORD,^CPWORD,^DISLOG,^ANQUERY,^RSCPCFG,^ECIOCFG,\r\n', 'OK\r\n'], 238 | 'AT+CPIN?\r': ['+CPIN: READY\r\n', 'OK\r\n']} 239 | self.commandsNoPinRequired = ['ATZ\r', 'ATE0\r', 'AT+CFUN?\r', 'AT+CFUN=1\r', 'AT+CMEE=1\r'] 240 | self.dtmfCommandBase = '^DTMF={cid},' 241 | 242 | def getAtdResponse(self, number): 243 | return ['OK\r\n'] 244 | 245 | def getPreCallInitWaitSequence(self): 246 | return [0.1] 247 | 248 | def getCallInitNotification(self, callId, callType): 249 | return ['^ORIG:{0},{1}\r\n'.format(callId, callType), 0.2, '^CONF:{0}\r\n'.format(callId)] 250 | 251 | def getRemoteAnsweredNotification(self, callId, callType): 252 | return ['^CONN:{0},{1}\r\n'.format(callId, callType)] 253 | 254 | def getRemoteHangupNotification(self, callId, callType): 255 | return ['^CEND:{0},5,29,16\r\n'.format(callId)] 256 | 257 | def getIncomingCallNotification(self, callerNumber, callType='VOICE', ton=145): 258 | return ['+CRING: {0}\r\n'.format(callType), '+CLIP: "{1}",{2},,,,0\r\n'.format(callType, callerNumber, ton)] 259 | 260 | def __str__(self): 261 | return 'Huawei K3715' 262 | 263 | 264 | class HuaweiE1752(FakeModem): 265 | """ Huawei E1752 modem (used by Cell C in South Africa) 266 | This modem issues "COMMAND NOT SUPPORT" non-standard error messages 267 | """ 268 | 269 | def __init__(self): 270 | super(HuaweiE1752, self).__init__() 271 | # This modem uses AT^USSDMODE to control text/PDU mode USSD 272 | self._ussdMode = 1 273 | self.responses = {'AT+CGMI\r': ['huawei\r\n', 'OK\r\n'], 274 | 'AT+CGMM\r': ['E1752\r\n', 'OK\r\n'], 275 | 'AT+CGMR\r': ['11.126.13.00.00\r\n', 'OK\r\n'], 276 | 'AT+CIMI\r': ['111111111111111\r\n', 'OK\r\n'], 277 | 'AT+CGSN\r': ['111111111111111\r\n', 'OK\r\n'], 278 | 'AT+CPMS=?\r': ['+CPMS: ("ME","MT","SM","SR"),("ME","MT","SM","SR"),("ME","MT","SM","SR")\r\n', 'OK\r\n'], 279 | # Note the non-standard "COMMAND NOT SUPPORT" error message 280 | 'AT+WIND?\r': ['COMMAND NOT SUPPORT\r\n'], 281 | 'AT+WIND=50\r': ['COMMAND NOT SUPPORT\r\n'], 282 | 'AT+ZPAS?\r': ['COMMAND NOT SUPPORT\r\n'], 283 | # Modem has non-standard +CLAC response (does not start with +CLAC:, and extra \r added to each line (i.e. as part of the command name) 284 | 'AT+CLAC\r': ['&C\r\r\n', '&D\r\r\n', '&F\r\r\n', '&V\r\r\n', 'E\r\r\n', 'I\r\r\n', 'L\r\r\n', 'M\r\r\n', 285 | 'Q\r\r\n', 'V\r\r\n', 'X\r\r\n', 'Z\r\r\n', 'T\r\r\n', 'P\r\r\n', 'D\r\r\n', 'A\r\r\n', 286 | 'H\r\r\n', 'O\r\r\n', 'S0\r\r\n', 'S2\r\r\n', 'S3\r\r\n', 'S4\r\r\n', 'S5\r\r\n', 'S6\r\r\n', 287 | 'S7\r\r\n', 'S8\r\r\n', 'S9\r\r\n', 'S10\r\r\n', 'S11\r\r\n', 'S30\r\r\n', 'S103\r\r\n', 288 | 'S104\r\r\n', '+FCLASS\r\r\n', '+ICF\r\r\n', '+IFC\r\r\n', '+IPR\r\r\n', '+GMI\r\r\n', 289 | '+GMM\r\r\n', '+GMR\r\r\n', '+GCAP\r\r\n', '+GSN\r\r\n', '+DR\r\r\n', '+DS\r\r\n', 290 | '+WS46\r\r\n', '+CLAC\r\r\n', '+CCLK\r\r\n', '+CBST\r\r\n', '+CRLP\r\r\n', '+CV120\r\r\n', 291 | '+CHSN\r\r\n', '+CSSN\r\r\n', '+CREG\r\r\n', '+CGREG\r\r\n', '+CFUN\r\r\n', '+GCAP\r\r\n', 292 | '+CSCS\r\r\n', '+CSTA\r\r\n', '+CR\r\r\n', '+CEER\r\r\n', '+CRC\r\r\n', '+CMEE\r\r\n', 293 | '+CGDCONT\r\r\n', '+CGDSCONT\r\r\n', '+CGTFT\r\r\n', '+CGEQREQ\r\r\n', '+CGEQMIN\r\r\n', 294 | '+CGQREQ\r\r\n', '+CGQMIN\r\r\n', '+CGEQNEG\r\r\n', '+CGEREP\r\r\n', '+CGPADDR\r\r\n', 295 | '+CGCLASS\r\r\n', '+CGSMS\r\r\n', '+CSMS\r\r\n', '+CMGF\r\r\n', '+CSAS\r\r\n', '+CRES\r\r\n', 296 | '+CSCA\r\r\n', '+CSMP\r\r\n', '+CSDH\r\r\n', '+CSCB\r\r\n', '+FDD\r\r\n', '+FAR\r\r\n', 297 | '+FCL\r\r\n', '+FIT\r\r\n', '+ES\r\r\n', '+ESA\r\r\n', '+CMOD\r\r\n', '+CVHU\r\r\n', 298 | '+CGDATA\r\r\n', '+CSQ\r\r\n', '+CBC\r\r\n', '+CPAS\r\r\n', '+CPIN\r\r\n', '+CMEC\r\r\n', 299 | '+CKPD\r\r\n', '+CIND\r\r\n', '+CMER\r\r\n', '+CGATT\r\r\n', '+CGACT\r\r\n', '+CGCMOD\r\r\n', 300 | '+CPBS\r\r\n', '+CPBR\r\r\n', '+CPBF\r\r\n', '+CPBW\r\r\n', '+CPMS\r\r\n', '+CNMI\r\r\n', 301 | '+CMGL\r\r\n', '+CMGR\r\r\n', '+CMGS\r\r\n', '+CMSS\r\r\n', '+CMGW\r\r\n', '+CMGD\r\r\n', 302 | '+CMGC\r\r\n', '+CNMA\r\r\n', '+CMMS\r\r\n', '+FTS\r\r\n', '+FRS\r\r\n', '+FTH\r\r\n', 303 | '+FRH\r\r\n', '+FTM\r\r\n', '+FRM\r\r\n', '+CHUP\r\r\n', '+CCFC\r\r\n', '+CCUG\r\r\n', 304 | '+COPS\r\r\n', '+CLCK\r\r\n', '+CPWD\r\r\n', '+CUSD\r\r\n', '+CAOC\r\r\n', '+CACM\r\r\n', 305 | '+CAMM\r\r\n', '+CPUC\r\r\n', '+CCWA\r\r\n', '+CHLD\r\r\n', '+CIMI\r\r\n', '+CGMI\r\r\n', 306 | '+CGMM\r\r\n', '+CGMR\r\r\n', '+CGSN\r\r\n', '+CNUM\r\r\n', '+CSIM\r\r\n', '+CRSM\r\r\n', 307 | '+CCLK\r\r\n', '+CLVL\r\r\n', '+CMUT\r\r\n', '+CLCC\r\r\n', '+COPN\r\r\n', '+CPOL\r\r\n', 308 | '+CPLS\r\r\n', '+CTZR\r\r\n', '+CTZU\r\r\n', '+CLAC\r\r\n', '+CLIP\r\r\n', '+COLP\r\r\n', 309 | '+CDIP\r\r\n', '+CTFR\r\r\n', '+CLIR\r\r\n', '$QCSIMSTAT\r\r\n', '$QCCNMI\r\r\n', 310 | '$QCCLR\r\r\n', '$QCDMG\r\r\n', '$QCDMR\r\r\n', '$QCDNSP\r\r\n', '$QCDNSS\r\r\n', 311 | '$QCTER\r\r\n', '$QCSLOT\r\r\n', '$QCPINSTAT\r\r\n', '$QCPDPP\r\r\n', '$QCPDPLT\r\r\n', 312 | '$QCPWRDN\r\r\n', '$QCDGEN\r\r\n', '$BREW\r\r\n', '$QCSYSMODE\r\r\n', '$QCCTM\r\r\n', 313 | '^RFSWITCH\r\r\n', '^SOFTSWITCH\r\r\n', '^FLIGHTMODESAVE\r\r\n', '^IMSICHG\r\r\n', 314 | '^STSF\r\r\n', '^STGI\r\r\n', '^STGR\r\r\n', '^CELLMODE\r\r\n', '^SYSINFO\r\r\n', 315 | '^DIALMODE\r\r\n', '^SYSCFG\r\r\n', '^SYSCONFIG\r\r\n', '^HS\r\r\n', '^DTMF\r\r\n', 316 | '^CPBR\r\r\n', '^CPBW\r\r\n', '^HWVER\r\r\n', '^HVER\r\r\n', '^DSFLOWCLR\r\r\n', 317 | '^DSFLOWQRY\r\r\n', '^DSFLOWRPT\r\r\n', '^SPN\r\r\n', '^PORTSEL\r\r\n', '^CPIN\r\r\n', 318 | '^SN\r\r\n', '^EARST\r\r\n', '^CARDLOCK\r\r\n', '^CARDUNLOCK\r\r\n', '^ATRECORD\r\r\n', 319 | '^CDUR\r\r\n', '^BOOT\r\r\n', '^FHVER\r\r\n', '^CURC\r\r\n', '^FREQLOCK\r\r\n', 320 | '^FREQPREF\r\r\n', '^HSPA\r\r\n', '^HSUPA\r\r\n', '^GPSTYPE\r\r\n', '^HSDPA\r\r\n', 321 | '^GLASTERR\r\r\n', '^CARDMODE\r\r\n', '^U2DIAG\r\r\n', '^RSTRIGGER\r\r\n', '^SETPID\r\r\n', 322 | '^SCSITIMEOUT\r\r\n', '^CQI\r\r\n', '^GETPORTMODE\r\r\n', '^CVOICE\r\r\n', '^DDSETEX\r\r\n', 323 | '^pcmrecord\r\r\n', '^CSNR\r\r\n', '^CMSR\r\r\n', '^CMMT\r\r\n', '^CMGI\r\r\n', '^RDCUST\r\r\n', 324 | '^OPWORD\r\r\n', '^CPWORD\r\r\n', '^DISLOG\r\r\n', '^FPLMN\r\r\n', '^FPLMNCTRL\r\r\n', 325 | '^ANQUERY\r\r\n', '^RSCPCFG\r\r\n', '^ECIOCFG\r\r\n', '^IMSICHECK\r\r\n', '^USSDMODE\r\r\n', 326 | '^SLOTCFG\r\r\n', '^YJCX\r\r\n', '^NDISDUP\r\r\n', '^DHCP\r\r\n', '^AUTHDATA\r\r\n', 327 | '^CRPN\r\r\n', '^ICCID\r\r\n', '^NVMBN\r\r\n', '^RXDIV\r\r\n', '^DNSP\r\r\n', '^DNSS\r\r\n', 328 | '^WPDST\r\r\n', '^WPDOM\r\r\n', '^WPDFR\r\r\n', '^WPQOS\r\r\n', '^WPDSC\r\r\n', '^WPDGP\r\r\n', 329 | '^WPEND\r\r\n', '^WNICT\r\r\n', '^SOCKETCONT\r\r\n', '^WPURL\r\r\n', '^WMOLR\r\r\n', 330 | '^SECTIME\r\r\n', '^WPDNP\r\r\n', '^WPDDL\r\r\n', '^WPDCP\r\r\n', 'OK\r\n'], 331 | 'AT+CPIN?\r': ['+CPIN: READY\r\n', 'OK\r\n']} 332 | self.commandsNoPinRequired = ['ATZ\r', 'ATE0\r', 'AT+CFUN?\r', 'AT+CFUN=1\r', 'AT+CMEE=1\r'] 333 | self.dtmfCommandBase = '^DTMF={cid},' 334 | 335 | def getResponse(self, cmd): 336 | # Device defaults to ^USSDMODE == 1 337 | if cmd.startswith('AT+CUSD=1') and self._ussdMode == 1: 338 | return ['ERROR\r\n'] 339 | elif cmd.startswith('AT^USSDMODE='): 340 | self._ussdMode = int(cmd[12]) 341 | return super(HuaweiE1752, self).getResponse(cmd) 342 | else: 343 | return super(HuaweiE1752, self).getResponse(cmd) 344 | 345 | def getAtdResponse(self, number): 346 | return ['OK\r\n'] 347 | 348 | def getPreCallInitWaitSequence(self): 349 | return [0.1] 350 | 351 | def getCallInitNotification(self, callId, callType): 352 | return ['^ORIG:{0},{1}\r\n'.format(callId, callType), 0.2, '^CONF:{0}\r\n'.format(callId)] 353 | 354 | def getRemoteAnsweredNotification(self, callId, callType): 355 | return ['^CONN:{0},{1}\r\n'.format(callId, callType)] 356 | 357 | def getRemoteHangupNotification(self, callId, callType): 358 | return ['^CEND:{0},5,29,16\r\n'.format(callId)] 359 | 360 | def getIncomingCallNotification(self, callerNumber, callType='VOICE', ton=145): 361 | return ['+CRING: {0}\r\n'.format(callType), '+CLIP: "{1}",{2},,,,0\r\n'.format(callType, callerNumber, ton)] 362 | 363 | def __str__(self): 364 | return 'Huawei E1752' 365 | 366 | 367 | class QualcommM6280(FakeModem): 368 | """ Qualcomm/ZTE modem information provided by davidphiliplee on github """ 369 | 370 | def __init__(self): 371 | super(QualcommM6280, self).__init__() 372 | self._callState = 2 373 | self._callNumber = None 374 | self._callId = None 375 | self.commandsNoPinRequired = [] # This modem requires the CPIN command to be issued first 376 | self.commandsSimBusy = ['AT+CSCA?\r'] # Issue #10 on github 377 | self.responses = {'AT+CGMI\r': ['QUALCOMM INCORPORATED\r\n', 'OK\r\n'], 378 | 'AT+CGMM\r': ['M6280\r\n', 'OK\r\n'], 379 | 'AT+CGMR\r': ['M6280_V1.0.0 M6280_V1.0.0 1 [Sep 4 2008 12:00:00]\r\n', 'OK\r\n'], 380 | 'AT+CIMI\r': ['111111111111111\r\n', 'OK\r\n'], 381 | 'AT+CGSN\r': ['111111111111111\r\n', 'OK\r\n'], 382 | 'AT+CLAC\r': ['ERROR\r\n'], 383 | 'AT+WIND?\r': ['ERROR\r\n'], 384 | 'AT+WIND=50\r': ['ERROR\r\n'], 385 | 'AT+ZPAS?\r': ['+BEARTYPE: "UMTS","CS_PS"\r\n', 'OK\r\n'], 386 | 'AT+CPMS=?\r': ['+CPMS: ("ME","MT","SM","SR"),("ME","MT","SM","SR"),("ME","MT","SM","SR")\r\n', 'OK\r\n'], 387 | 'AT+CVHU=0\r': ['+CVHU: (0-1)\r\n', 'OK\r\n'], 388 | 'AT+CPIN?\r': ['+CPIN: READY\r\n', 'OK\r\n']} 389 | 390 | def getResponse(self, cmd): 391 | if not self._pinLock: 392 | if cmd.startswith('AT+CSMP='): 393 | # Clear the SMSC number (this behaviour was reported in issue #8 on github) 394 | self.smscNumber = None 395 | elif cmd == 'AT+CLCC\r': 396 | if self._callNumber: 397 | if self._callState == 0: 398 | return ['+CLCC: 1,0,2,0,0,"{0}",129\r\n'.format(self._callNumber), 'OK\r\n'] 399 | elif self._callState == 1: 400 | return ['+CLCC: 1,0,0,0,0,"{0}",129\r\n'.format(self._callNumber), 'OK\r\n'] 401 | else: 402 | return ['OK\r\n'] 403 | return super(QualcommM6280, self).getResponse(cmd) 404 | else: 405 | return super(QualcommM6280, self).getResponse(cmd) 406 | 407 | def getAtdResponse(self, number): 408 | self._callNumber = number 409 | self._callState = 0 410 | return [] 411 | 412 | def getPreCallInitWaitSequence(self): 413 | return [0.1] 414 | 415 | def getCallInitNotification(self, callId, callType): 416 | return [] 417 | 418 | def getRemoteAnsweredNotification(self, callId, callType): 419 | return ['CONNECT\r\n'] 420 | 421 | def getRemoteHangupNotification(self, callId, callType): 422 | self._callState = 2 423 | self._callNumber = None 424 | return ['HANGUP: {0}\r\n'.format(callId)] 425 | 426 | def getIncomingCallNotification(self, callerNumber, callType='VOICE', ton=145): 427 | return ['+CRING: {0}\r\n'.format(callType), '+CLIP: "{1}",{2},,,,0\r\n'.format(callType, callerNumber, ton)] 428 | 429 | def __str__(self): 430 | return 'QUALCOMM M6280 (ZTE modem)' 431 | 432 | 433 | class ZteK3565Z(FakeModem): 434 | """ ZTE K3565-Z (Vodafone branded) """ 435 | 436 | def __init__(self): 437 | super(ZteK3565Z, self).__init__() 438 | self._callState = 2 439 | self._callNumber = None 440 | self._callId = None 441 | self.commandsNoPinRequired = [] # This modem requires the CPIN command to be issued first 442 | self.responses = {'AT+CGMI\r': ['ZTE INCORPORATED\r\n', 'OK\r\n'], 443 | 'AT+CGMM\r': ['K3565-Z\r\n', 'OK\r\n'], 444 | 'AT+CGMR\r': ['BD_P673A2V1.0.0B09\r\n', 'OK\r\n'], 445 | 'AT+CFUN?\r': ['+CFUN: (0-1,4-7),(0-1)\r\n', 'OK\r\n'], 446 | 'AT+CIMI\r': ['111111111111111\r\n', 'OK\r\n'], 447 | 'AT+CGSN\r': ['111111111111111\r\n', 'OK\r\n'], 448 | # Note that AT+CLAC does NOT respond in the standard "+CLAC:" format 449 | 'AT+CLAC\r': ['&C\r\n', '&D\r\n', '&E\r\n', '&F\r\n', '&S\r\n', '&V\r\n', '&W\r\n', 'E\r\n', 'I\r\n', 450 | 'L\r\n', 'M\r\n', 'Q\r\n', 'V\r\n', 'X\r\n', 'Z\r\n', 'T\r\n', 'P\r\n', '\\Q\r\n', '\\S\r\n', 451 | '\\V\r\n', '%V\r\n', 'D\r\n', 'A\r\n', 'H\r\n', 'O\r\n', 'S0\r\n', 'S2\r\n', 'S3\r\n', 'S4\r\n', 452 | 'S5\r\n', 'S6\r\n', 'S7\r\n', 'S8\r\n', 'S9\r\n', 'S10\r\n', 'S11\r\n', 'S30\r\n', 'S103\r\n', 453 | 'S104\r\n', '+FCLASS\r\n', '+ICF\r\n', '+IFC\r\n', '+IPR\r\n', '+GMI\r\n', '+GMM\r\n', 454 | '+GMR\r\n', '+GCAP\r\n', '+GSN\r\n', '+DR\r\n', '+DS\r\n', '+WS46\r\n', '+CBST\r\n', '+CRLP\r\n', 455 | '+CV120\r\n', '+CHSN\r\n', '+CSSN\r\n', '+CREG\r\n', '+CGREG\r\n', '+CFUN\r\n', '+GCAP\r\n', 456 | '+CSCS\r\n', '+CSTA\r\n', '+CR\r\n', '+CEER\r\n', '+CRC\r\n', '+CMEE\r\n', '+CGDCONT\r\n', 457 | '+CGDSCONT\r\n', '+CGTFT\r\n', '+CGEQREQ\r\n', '+CGEQMIN\r\n', '+CGQREQ\r\n', '+CGQMIN\r\n', 458 | '+CGEREP\r\n', '+CGPADDR\r\n', '+CGDATA\r\n', '+CGCLASS\r\n', '+CGSMS\r\n', '+CSMS\r\n', 459 | '+CMGF\r\n', '+CSAS\r\n', '+CRES\r\n', '+CSCA\r\n', '+CSMP\r\n', '+CSDH\r\n', '+CSCB\r\n', 460 | '+FDD\r\n', '+FAR\r\n', '+FCL\r\n', '+FIT\r\n', '+ES\r\n', '+ESA\r\n', '+CMOD\r\n', '+CVHU\r\n', 461 | '+CSQ\r\n', '+ZRSSI\r\n', '+CBC\r\n', '+CPAS\r\n', '+CPIN\r\n', '+CMEC\r\n', '+CKPD\r\n', 462 | '+CGATT\r\n', '+CGACT\r\n', '+CGCMOD\r\n', '+CPBS\r\n', '+CPBR\r\n', '+ZCPBR\r\n', 463 | '+ZUSIM\r\n', '+CPBF\r\n', '+CPBW\r\n', '+ZCPBW\r\n', '+CPMS\r\n', '+CNMI\r\n', 464 | '+CMGL\r\n', '+CMGR\r\n', '+CMGS\r\n', '+CMSS\r\n', '+CMGW\r\n', '+CMGD\r\n', '+CMGC\r\n', 465 | '+CNMA\r\n', '+CMMS\r\n', '+CHUP\r\n', '+CCFC\r\n', '+CCUG\r\n', '+COPS\r\n', '+CLCK\r\n', 466 | '+CPWD\r\n', '+CUSD\r\n', '+CAOC\r\n', '+CACM\r\n', '+CAMM\r\n', '+CPUC\r\n', '+CCWA\r\n', 467 | '+CHLD\r\n', '+CIMI\r\n', '+CGMI\r\n', '+CGMM\r\n', '+CGMR\r\n', '+CGSN\r\n', '+CNUM\r\n', 468 | '+CSIM\r\n', '+CRSM\r\n', '+CCLK\r\n', '+CLVL\r\n', '+CMUT\r\n', '+CLCC\r\n', '+COPN\r\n', 469 | '+CPOL\r\n', '+CPLS\r\n', '+CTZR\r\n', '+CTZU\r\n', '+CLAC\r\n', '+CLIP\r\n', '+COLP\r\n', 470 | '+CDIP\r\n', '+CTFR\r\n', '+CLIR\r\n', '$QCSIMSTAT\r\n', '$QCCNMI\r\n', '$QCCLR\r\n', 471 | '$QCDMG\r\n', '$QCDMR\r\n', '$QCDNSP\r\n', '$QCDNSS\r\n', '$QCTER\r\n', '$QCSLOT\r\n', 472 | '$QCPINSTAT\r\n', '$QCPDPP\r\n', '$QCPDPLT\r\n', '$QCPWRDN\r\n', '$QCDGEN\r\n', 473 | '$BREW\r\n', '$QCSYSMODE\r\n', 'OK\r\n'], 474 | 'AT+WIND?\r': ['ERROR\r\n'], 475 | 'AT+WIND=50\r': ['ERROR\r\n'], 476 | 'AT+ZPAS?\r': ['+BEARTYPE: "UMTS","CS_PS"\r\n', 'OK\r\n'], 477 | 'AT+CPMS=?\r': ['+CPMS: ("ME","MT","SM","SR"),("ME","MT","SM","SR"),("ME","MT","SM","SR")\r\n', 'OK\r\n'], 478 | 'AT+CVHU=0\r': ['+CVHU: (0-1)\r\n', 'OK\r\n'], 479 | 'AT+CPIN?\r': ['+CPIN: READY\r\n', 'OK\r\n']} 480 | 481 | def getResponse(self, cmd): 482 | if not self._pinLock: 483 | if cmd.startswith('AT+CSMP='): 484 | # Clear the SMSC number (this behaviour was reported in issue #8 on github) 485 | self.smscNumber = None 486 | elif cmd == 'AT+CLCC\r': 487 | if self._callNumber: 488 | if self._callState == 0: 489 | return ['+CLCC: 1,0,2,0,0,"{0}",129\r\n'.format(self._callNumber), 'OK\r\n'] 490 | elif self._callState == 1: 491 | return ['+CLCC: 1,0,0,0,0,"{0}",129\r\n'.format(self._callNumber), 'OK\r\n'] 492 | else: 493 | return ['OK\r\n'] 494 | return super(ZteK3565Z, self).getResponse(cmd) 495 | else: 496 | return super(ZteK3565Z, self).getResponse(cmd) 497 | 498 | def getAtdResponse(self, number): 499 | self._callNumber = number 500 | self._callState = 0 501 | return [] 502 | 503 | def getPreCallInitWaitSequence(self): 504 | return [0.1] 505 | 506 | def getCallInitNotification(self, callId, callType): 507 | return [] 508 | 509 | def getRemoteAnsweredNotification(self, callId, callType): 510 | return ['CONNECT\r\n'] 511 | 512 | def getRemoteHangupNotification(self, callId, callType): 513 | self._callState = 2 514 | self._callNumber = None 515 | return ['HANGUP: {0}\r\n'.format(callId)] 516 | 517 | def getRemoteRejectCallNotification(self, callId, callType): 518 | self._callState = 2 519 | self._callNumber = None 520 | return ["OK\r\n"] 521 | 522 | def getIncomingCallNotification(self, callerNumber, callType='VOICE', ton=145): 523 | return ['+CRING: {0}\r\n'.format(callType), '+CLIP: "{1}",{2},,,,0\r\n'.format(callType, callerNumber, ton)] 524 | 525 | def __str__(self): 526 | return 'ZTE K3565-Z' 527 | 528 | 529 | class NokiaN79(GenericTestModem): 530 | """ Nokia Symbian S60-based modem (details taken from a Nokia N79) and 531 | also from issue 15: https://github.com/faucamp/python-gsmmodem/issues/15 (Nokia N95) 532 | 533 | SMS reading is not supported on these devices via AT commands; thus 534 | commands like AT+CNMI are not supported. 535 | """ 536 | 537 | def __init__(self): 538 | super(NokiaN79, self).__init__() 539 | self.responses = {'AT+CGMI\r': ['Nokia\r\n', 'OK\r\n'], 540 | 'AT+CGMM\r': ['Nokia N79\r\n', 'OK\r\n'], 541 | 'AT+CGMR\r': ['V ICPR72_08w44.1\r\n', '24-11-08\r\n', 'RM-348\r\n', '(c) Nokia\r\n', '11.049\r\n', 'OK\r\n'], 542 | 'AT+CIMI\r': ['111111111111111\r\n', 'OK\r\n'], 543 | 'AT+CGSN\r': ['111111111111111\r\n', 'OK\r\n'], 544 | 'AT+CNMI=2,1,0,2\r': ['ERROR\r\n'], # SMS reading and notifications not supported 545 | 'AT+CLAC\r': ['ERROR\r\n'], 546 | 'AT+WIND?\r': ['ERROR\r\n'], 547 | 'AT+WIND=50\r': ['ERROR\r\n'], 548 | 'AT+ZPAS?\r': ['ERROR\r\n'], 549 | 'AT+CPMS="SM","SM","SR"\r': ['ERROR\r\n'], 550 | 'AT+CPMS=?\r': ['+CPMS: (),(),()\r\n', 'OK\r\n'], # not supported 551 | 'AT+CPMS?\r': ['+CPMS: ,,,,,,,,\r\n', 'OK\r\n'], # not supported 552 | 'AT+CPMS=,,\r': ['ERROR\r\n'], 553 | 'AT+CPMS="SM","SM"\r': ['ERROR\r\n'], # not supported 554 | 'AT+CSMP?\r': ['+CSMP: 49,167,0,0\r\n', 'OK\r\n'], 555 | 'AT+GCAP\r': ['+GCAP: +CGSM,+DS,+W\r\n', 'OK\r\n'], 556 | 'AT+CNMI=2,1,0,2\r': ['ERROR\r\n'], # not supported 557 | 'AT+CVHU=0\r': ['OK\r\n'], 558 | 'AT+CPIN?\r': ['+CPIN: READY\r\n', 'OK\r\n']} 559 | self.commandsNoPinRequired = ['ATZ\r', 'ATE0\r', 'AT+CFUN?\r', 'AT+CFUN=1\r', 'AT+CMEE=1\r'] 560 | 561 | def __str__(self): 562 | return 'Nokia N79' 563 | 564 | 565 | modemClasses = [HuaweiK3715, HuaweiE1752, WavecomMultiband900E1800, QualcommM6280, ZteK3565Z, NokiaN79] 566 | 567 | 568 | def createModems(): 569 | return [modem() for modem in modemClasses] 570 | -------------------------------------------------------------------------------- /test/test_gsmterm.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ Test suite for GsmTerm """ 4 | 5 | import sys, unittest 6 | 7 | from . import compat # For Python 2.6 compatibility 8 | try: 9 | import gsmtermlib.trie 10 | except ImportError: 11 | # "python -m unittest discover" run from project root 12 | sys.path.insert(0, 'tools') 13 | import gsmtermlib.trie 14 | 15 | class TestTrie(unittest.TestCase): 16 | """ Tests the trie implementation used by GsmTerm """ 17 | 18 | def setUp(self): 19 | self.trie = gsmtermlib.trie.Trie() 20 | self.keyValuePairs = (('abc', 'def'), 21 | ('hallo', 'daar'), 22 | ('hoe gaan', 'dit met jou'), 23 | ('sbzd', '123'), 24 | ('abcde', '234627sdg'), 25 | ('ab', 'asdk;jgdjsagkl')) 26 | 27 | def test_storeSingle(self): 28 | """ Tests single key/value pair storage """ 29 | self.trie['hallo'] = 'daar' 30 | self.assertEqual(self.trie['hallo'], 'daar') 31 | self.assertEqual(len(self.trie), 1) 32 | self.assertRaises(KeyError, self.trie.__getitem__, 'abc') 33 | # Set/get None key 34 | self.assertRaises(ValueError, self.trie.__setitem__, None, 'someValue') 35 | self.assertRaises(ValueError, self.trie.__getitem__, None) 36 | # Store zero-length key 37 | self.assertRaises(KeyError, self.trie.__getitem__, '') 38 | self.trie[''] = '123' 39 | self.assertEqual(self.trie[''], '123') 40 | self.assertEqual(len(self.trie), 2) 41 | 42 | def test_deleteSingle(self): 43 | """ Tests deleting single key/value pair """ 44 | self.trie['hallo'] = 'daar' 45 | self.assertEqual(self.trie['hallo'], 'daar') 46 | self.assertEqual(len(self.trie), 1) 47 | del self.trie['hallo'] 48 | self.assertRaises(KeyError, self.trie.__getitem__, 'hallo') 49 | self.assertEqual(len(self.trie), 0) 50 | # Delete None key 51 | self.assertRaises(ValueError, self.trie.__delitem__, None) 52 | # Delete unknown key 53 | self.assertRaises(KeyError, self.trie.__delitem__, 'unknown key') 54 | # Delete zero-length unknown key 55 | self.assertRaises(KeyError, self.trie.__delitem__, '') 56 | # Delete zero-lenght known key 57 | self.trie[''] = '123' 58 | self.assertEqual(len(self.trie), 1) 59 | del self.trie[''] 60 | self.assertEqual(len(self.trie), 0) 61 | 62 | def test_storeRetrieveMultiple(self): 63 | n = 0 64 | for key, value in self.keyValuePairs: 65 | n += 1 66 | self.trie[key] = value 67 | self.assertEqual(self.trie[key], value) 68 | # Make sure nothing was lost 69 | for oldKey, oldValue in self.keyValuePairs[:n-1]: 70 | self.assertEqual(self.trie[oldKey], oldValue) 71 | 72 | def test_storeDeleteMultiple(self): 73 | self.assertEqual(len(self.trie), 0) 74 | for key, value in self.keyValuePairs: 75 | self.trie[key] = value 76 | self.assertEqual(len(self.trie), len(self.keyValuePairs)) 77 | n = len(self.trie) 78 | for key, value in self.keyValuePairs: 79 | n -= 1 80 | del self.trie[key] 81 | self.assertEqual(len(self.trie), n) 82 | self.assertEqual(len(self.trie), 0) 83 | 84 | def test_len(self): 85 | n = 0 86 | for key, value in self.keyValuePairs: 87 | n += 1 88 | self.trie[key] = value 89 | self.assertEqual(len(self.trie), n, 'Incorrect trie length. Expected {0}, got {1}. Last entry: {2}: {3}'.format(n, len(self.trie), key, value)) 90 | 91 | def test_contains(self): 92 | for key, value in self.keyValuePairs: 93 | self.assertFalse(key in self.trie) 94 | self.trie[key] = value 95 | self.assertTrue(key in self.trie) 96 | 97 | def test_getMethod(self): 98 | # Test invalid None key 99 | self.assertRaises(ValueError, self.trie.get, None) 100 | # Test unknown key 101 | self.assertEqual(self.trie.get('abc'), None) # no default 102 | self.assertEqual(self.trie.get('abc', default='def'), 'def') # with default 103 | self.trie['abc'] = '123' 104 | self.assertEqual(self.trie.get('abc'), '123') # no default 105 | self.assertEqual(self.trie.get('abc', default='def'), '123') # with default 106 | 107 | def test_keys(self): 108 | """ Test the "keys" method of the trie """ 109 | localKeys = [] 110 | for key, value in self.keyValuePairs: 111 | localKeys.append(key) 112 | self.trie[key] = value 113 | # The trie has no concept of ordering, so we can't simply compare keys with == 114 | trieKeys = self.trie.keys() 115 | self.assertEqual(len(trieKeys), len(localKeys)) 116 | for key in localKeys: 117 | self.assertTrue(key in trieKeys) 118 | 119 | def test_overWrite(self): 120 | # Fill up trie with some values 121 | for key, value in self.keyValuePairs: 122 | self.trie[key] = value 123 | key, oldValue = self.keyValuePairs[0] 124 | length = len(self.keyValuePairs) 125 | self.assertEqual(self.trie[key], oldValue) 126 | self.assertEqual(len(self.trie), length) 127 | # Overwrite value 128 | newValue = oldValue + '12345' 129 | self.assertNotEqual(oldValue, newValue) 130 | self.trie[key] = newValue 131 | # Read it back 132 | self.assertEqual(self.trie[key], newValue) 133 | # Check trie length is unchanged 134 | self.assertEqual(len(self.trie), length) 135 | 136 | def test_filteredKeys(self): 137 | """ Test the "matching keys" functionality of the trie """ 138 | keys = ('a', 'ab', 'abc', 'abcd0000', 'abcd1111', 'abcd2222', 'abcd3333', 'b000', 'b1111', 'zzz123', 'zzzz1234', 'xyz123', 'AT+CSCS') 139 | prefixMatches = (('abc', [key for key in keys if key.startswith('abc')]), 140 | ('b', [key for key in keys if key.startswith('b')]), 141 | ('bc', [key for key in keys if key.startswith('bc')]), 142 | ('zzz', [key for key in keys if key.startswith('zzz')]), 143 | ('x', [key for key in keys if key.startswith('x')]), 144 | ('xy', [key for key in keys if key.startswith('xy')]), 145 | ('qwerty', [key for key in keys if key.startswith('qwerty')]), 146 | ('AT+CSCS=', [key for key in keys if key.startswith('AT+CSCS=')])) 147 | 148 | for key in keys: 149 | self.trie[key] = 1 150 | for prefix, matchingKeys in prefixMatches: 151 | trieKeys = self.trie.keys(prefix) 152 | self.assertEqual(len(trieKeys), len(matchingKeys), 'Filtered keys length failed. Prefix: {0}, expected len: {1}, items: {2}, got len {3}, items: {4}'.format(prefix, len(matchingKeys), matchingKeys, len(trieKeys), trieKeys)) 153 | for key in matchingKeys: 154 | self.assertTrue(key in trieKeys, 'Key not in trie keys: {0}. Trie keys: {1}'.format(key, trieKeys)) 155 | 156 | def test_longestCommonPrefix(self): 157 | """ Test the "get longest common prefix" functionality of the trie """ 158 | keys = ('abcDEF', 'abc123', 'abcASFDDSFDSF', 'abc@#$@#$', 'abcDxxx') 159 | for key in keys: 160 | self.trie[key] = 1 161 | self.assertEqual(self.trie.longestCommonPrefix(), 'abc') 162 | self.assertEqual(self.trie.longestCommonPrefix('a'), 'abc') 163 | self.assertEqual(self.trie.longestCommonPrefix('ab'), 'abc') 164 | self.assertEqual(self.trie.longestCommonPrefix('abc'), 'abc') 165 | self.assertEqual(self.trie.longestCommonPrefix('abcD'), 'abcD') 166 | self.assertEqual(self.trie.longestCommonPrefix('abcDz'), '') 167 | self.assertEqual(self.trie.longestCommonPrefix('abcDE'), 'abcDEF') 168 | self.assertEqual(self.trie.longestCommonPrefix('abcDEF'), 'abcDEF') 169 | self.assertEqual(self.trie.longestCommonPrefix('abcDEz'), '') 170 | keys = ('ATD', 'ATDL') 171 | for key in keys: 172 | self.trie[key] = 1 173 | self.assertEqual(self.trie.longestCommonPrefix(), '') 174 | self.assertEqual(self.trie.longestCommonPrefix('A'), 'ATD') 175 | self.assertEqual(self.trie.longestCommonPrefix('AT'), 'ATD') 176 | self.assertEqual(self.trie.longestCommonPrefix('ATD'), 'ATD') 177 | 178 | def test_iter(self): 179 | localKeys = [] 180 | for key, value in self.keyValuePairs: 181 | localKeys.append(key) 182 | self.trie[key] = value 183 | n = 0 184 | a = iter(self.trie) 185 | while True: 186 | try: 187 | self.assertIn(next(a), localKeys) 188 | except StopIteration: 189 | break 190 | else: 191 | n += 1 192 | self.assertEqual(n, len(self.trie)) 193 | 194 | 195 | class TestAtCommands(unittest.TestCase): 196 | """ Test suite for the AT Commands data structure """ 197 | 198 | def test_loadAtCommands(self): 199 | """ Check that the AT commands can be loaded correctly, and they are correctly formatted """ 200 | from gsmtermlib.atcommands import ATCOMMANDS, CATEGORIES 201 | for command, help in ATCOMMANDS: 202 | self.assertNotEqual(command, None) 203 | self.assertGreater(len(command), 0) 204 | self.assertEqual(command.strip(), command, 'Command has leading and/or trailing spaces: {0}'.format(command)) 205 | 206 | self.assertNotEqual(help, None, 'Command\'s help tuple is None: {0}'.format(command)) 207 | self.assertGreaterEqual(len(help), 2) 208 | self.assertTrue(help[0] in CATEGORIES) 209 | if len(help) > 2: 210 | if help[2] != None: 211 | self.assertIsInstance(help[2], tuple) 212 | self.assertGreater(len(help[2]), 0) 213 | for item in help[2]: 214 | self.assertEqual(len(item), 2, 'Input value item tuple length should be 2, got {0}. Command: {1}, item: {2}'.format(len(item), command, item)) 215 | if help[3] != None: 216 | self.assertIsInstance(help[3], tuple) 217 | self.assertGreater(len(help[3]), 0) 218 | for item in help[3]: 219 | self.assertEqual(len(item), 2, 'Output value item tuple length should be 2, got {0}. Command: {1}, item: {2}'.format(len(item), command, item)) 220 | self.assertIsInstance(help[4], str) 221 | 222 | 223 | if __name__ == "__main__": 224 | unittest.main() 225 | -------------------------------------------------------------------------------- /test/test_pdu.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ Test suite for SMS PDU encoding/decoding algorithms """ 5 | 6 | from __future__ import unicode_literals 7 | 8 | import sys, unittest, random, codecs 9 | from datetime import datetime, timedelta 10 | 11 | from . import compat # For Python 2.6, 3.0-2 compatibility 12 | 13 | import gsmmodem.pdu 14 | from gsmmodem.util import SimpleOffsetTzInfo 15 | 16 | class TestSemiOctets(unittest.TestCase): 17 | """ Tests the semi-octet encoder/decoder """ 18 | 19 | def setUp(self): 20 | self.tests = (('15125551234', bytearray([0x51, 0x21, 0x55, 0x15, 0x32, 0xf4])), 21 | ('123', bytearray([0x21, 0xf3])), 22 | ('1234', bytearray([0x21, 0x43]))) 23 | 24 | def test_encode(self): 25 | """ Tests the semi-octet encoding algorithm """ 26 | for plaintext, encoded in self.tests: 27 | result = gsmmodem.pdu.encodeSemiOctets(plaintext) 28 | self.assertEqual(result, encoded, 'Failed to encode plaintext string: "{0}". Expected: "{1}", got: "{2}"'.format(plaintext, [b for b in encoded], [b for b in result])) 29 | 30 | def test_decode(self): 31 | """ Tests the semi-octet decoding algorithm """ 32 | for plaintext, encoded in self.tests: 33 | # Test different parameter types: bytearray, str 34 | for param in (encoded, codecs.encode(compat.str(encoded), 'hex_codec')): 35 | result = gsmmodem.pdu.decodeSemiOctets(param) 36 | self.assertEqual(result, plaintext, 'Failed to decode data. Expected: "{0}", got: "{1}"'.format(plaintext, result)) 37 | 38 | def test_decodeIter(self): 39 | """ Tests semi-octet decoding when using a bytearray iterator and number of octets as input argument """ 40 | iterTests = (('0123456789', 9, iter(bytearray(codecs.decode(b'1032547698', 'hex_codec')))),) 41 | for plaintext, numberOfOctets, byteIter in iterTests: 42 | result = gsmmodem.pdu.decodeSemiOctets(byteIter, numberOfOctets) 43 | self.assertEqual(result, plaintext, 'Failed to decode data iter. Expected: "{0}", got: "{1}"'.format(plaintext, result)) 44 | 45 | 46 | class TestGsm7(unittest.TestCase): 47 | """ Tests the GSM-7 encoding/decoding algorithms """ 48 | 49 | def setUp(self): 50 | self.tests = (('123', bytearray(b'123'), bytearray([49, 217, 12])), 51 | ('12345678', bytearray(b'12345678'), bytearray([49, 217, 140, 86, 179, 221, 112])), 52 | ('123456789', bytearray(b'123456789'), bytearray([49, 217, 140, 86, 179, 221, 112, 57])), 53 | ('Hello World!', bytearray([0x48, 0x65, 0x6C, 0x6C, 0x6F, 0x20, 0x57, 0x6F, 0x72, 0x6C, 0x64, 0x21]), bytearray([200, 50, 155, 253, 6, 93, 223, 114, 54, 57, 4])), 54 | ('[{abc}]~', bytearray([0x1B, 0x3C, 0x1B, 0x28, 0x61, 0x62, 0x63, 0x1B, 0x29, 0x1B, 0x3E, 0x1B, 0x3D]), bytearray([27, 222, 6, 21, 22, 143, 55, 169, 141, 111, 211, 3])), 55 | ('123456789012345678901234567890', bytearray([49, 50, 51, 52, 53, 54, 55, 56, 57, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 48]), 56 | bytearray([49, 217, 140, 86, 179, 221, 112, 57, 88, 76, 54, 163, 213, 108, 55, 92, 14, 22, 147, 205, 104, 53, 219, 13, 151, 131, 1])), 57 | ('{åΦΓΛΩΠΨΣΘ€}', bytearray([27, 40, 15, 18, 19, 20, 21, 22, 23, 24, 25, 27, 101, 27, 41]), bytearray([27, 212, 67, 50, 161, 84, 44, 23, 76, 102, 83, 222, 164, 0])), 58 | ('a[]{}€', bytearray([97, 27, 60, 27, 62, 27, 40, 27, 41, 27, 101]), bytearray([225, 13, 111, 227, 219, 160, 54, 169, 77, 25])), 59 | ) 60 | 61 | def test_encode(self): 62 | """ Tests GSM-7 encoding algorithm """ 63 | for plaintext, encoded, septets in self.tests: 64 | result = gsmmodem.pdu.encodeGsm7(plaintext) 65 | self.assertEqual(result, encoded, 'Failed to GSM-7 encode plaintext string: "{0}". Expected: "{1}", got: "{2}"'.format(plaintext, [b for b in encoded], [b for b in result])) 66 | 67 | def test_decode(self): 68 | """ Tests GSM-7 decoding algorithm """ 69 | for plaintext, encoded, septets in self.tests: 70 | # Test different parameter types: bytearray, str 71 | for param in (encoded, compat.bytearrayToStr(encoded)): 72 | result = gsmmodem.pdu.decodeGsm7(param) 73 | self.assertEqual(result, plaintext, 'Failed to decode GSM-7 string: "{0}". Expected: "{1}", got: "{2}"'.format([b for b in encoded], plaintext, result)) 74 | 75 | def test_packSeptets(self): 76 | """ Tests the septet-packing alogrithm for GSM-7-encoded strings """ 77 | for plaintext, encoded, septets in self.tests: 78 | # Test different parameter types: bytearray, str, iter(bytearray) 79 | i = 0 80 | for param in (encoded, compat.bytearrayToStr(encoded), iter(encoded)): 81 | result = gsmmodem.pdu.packSeptets(param) 82 | self.assertEqual(result, septets, 'Failed to pack GSM-7 octets into septets for string: "{0}" using parameter type: {1}. Expected: "{2}", got: "{3}"'.format(plaintext, type(param), [b for b in septets], [b for b in result])) 83 | i+=1 84 | 85 | def test_unpackSeptets_no_limits(self): 86 | """ Tests the septet-unpacking alogrithm for GSM-7-encoded strings (no maximum number of septets specified) """ 87 | for plaintext, encoded, septets in self.tests: 88 | # Test different parameter types: bytearray, str, iter(bytearray) 89 | for param in (septets, compat.bytearrayToStr(septets), iter(septets)): 90 | result = gsmmodem.pdu.unpackSeptets(param) 91 | self.assertEqual(result, encoded, 'Failed to unpack GSM-7 septets into octets for string: "{0}". Expected: "{1}", got: "{2}"'.format(plaintext, [b for b in encoded], [b for b in result])) 92 | 93 | def test_unpackSeptets_with_limits(self): 94 | """ Tests the septet-unpacking alogrithm for GSM-7-encoded strings (max number of septets specified) """ 95 | for plaintext, encoded, septets in self.tests: 96 | limit = len(septets) 97 | septets.extend([random.randint(0,255), random.randint(0,255), random.randint(0,255), random.randint(0,255)]) # add some garbage data (should be ignored due to numberOfSeptets being set) 98 | result = gsmmodem.pdu.unpackSeptets(septets, limit) 99 | self.assertEqual(result, encoded, 'Failed to unpack GSM-7 septets into {0} octets for string: "{1}". Expected: "{2}", got: "{3}"'.format(len(encoded), plaintext, [b for b in encoded], [b for b in result])) 100 | 101 | def test_encodeInvalid(self): 102 | """ Test encoding a string that cannot be encoded with GSM-7 """ 103 | tests = ('世界您好!',) 104 | for invalidStr in tests: 105 | self.assertRaises(ValueError, gsmmodem.pdu.encodeGsm7, invalidStr, discardInvalid=False) 106 | 107 | def test_encodeInvalidDiscard(self): 108 | """ Tests encoding a string containing invalid GSM-7 characters when set to discard them """ 109 | tests = (('a世界b您c好!', bytearray([97, 98, 99])),) 110 | for invalidStr, encoded in tests: 111 | result = gsmmodem.pdu.encodeGsm7(invalidStr, discardInvalid=True) 112 | self.assertEqual(result, encoded, 'Failed to GSM-7 encode invalid plaintext string: "{0}". Expected: "{1}", got: "{2}"'.format(invalidStr, [b for b in encoded], [b for b in result])) 113 | 114 | 115 | class TestUcs2(unittest.TestCase): 116 | """ Tests the UCS2 encoding/decoding algorithms """ 117 | 118 | def setUp(self): 119 | self.tests = (('あ叶葉', bytearray([0x30, 0x42, 0x53, 0xF6, 0x84, 0x49])), 120 | ('はい', bytearray([0x30, 0x6F, 0x30, 0x44]))) 121 | 122 | def test_encode(self): 123 | """ Tests GSM-7 encoding algorithm """ 124 | for plaintext, encoded in self.tests: 125 | result = gsmmodem.pdu.encodeUcs2(plaintext) 126 | self.assertEqual(result, encoded, 'Failed to UCS-2 encode plaintext string: "{0}". Expected: "{1}", got: "{2}"'.format(plaintext, [b for b in encoded], [b for b in result])) 127 | 128 | def test_decode(self): 129 | """ Tests GSM-7 decoding algorithm """ 130 | for plaintext, encoded in self.tests: 131 | result = gsmmodem.pdu.decodeUcs2(iter(encoded), len(encoded)) 132 | self.assertEqual(result, plaintext, 'Failed to decode UCS-2 string: "{0}". Expected: "{1}", got: "{2}"'.format([b for b in encoded], plaintext, result)) 133 | 134 | 135 | class TestSmsPduAddressFields(unittest.TestCase): 136 | """ Tests for SMS PDU address fields (these methods are not meant to be public) """ 137 | 138 | def setUp(self): 139 | self.tests = (('+9876543210', 7, b'0A918967452301', b'0A918967452301'), 140 | ('+9876543210', 7, b'0A918967452301000000', b'0A918967452301'), # same as above, but checking read limits 141 | ('+987654321', 7, b'099189674523F1000000', b'099189674523F1'), 142 | ('+27829135934', 8, b'0B917228195339F4', b'0B917228195339F4'), 143 | ('abc', 5, b'06D061F118', b'06D061F118'), 144 | ('abc', 5, b'06D061F118D3F1FF0032', b'06D061F118'), # same as above, but checking read limits 145 | ('FRANCOIS', 9, b'0ED04669D0397C26A7', b'0ED04669D0397C26A7'), 146 | ('a[]{}€', 12, b'14D0E10D6FE3DBA036A94D19', b'14D0E10D6FE3DBA036A94D19'), 147 | ('0129998765', 7, b'0AA11092997856', b'0AA11092997856') # local number 148 | ) 149 | 150 | def test_decodeAddressField(self): 151 | for plaintext, bytesRead, hexEncoded, realHexEncoded in self.tests: 152 | byteIter = iter(bytearray(codecs.decode(hexEncoded, 'hex_codec'))) 153 | resultValue, resultNumBytesRead = gsmmodem.pdu._decodeAddressField(byteIter, log=True) 154 | self.assertEqual(resultValue, plaintext, 'Failed to decode address field data "{0}". Expected: "{1}", got: "{2}"'.format(hexEncoded, plaintext, resultValue)) 155 | self.assertEqual(resultNumBytesRead, bytesRead, 'Incorrect "number of bytes read" returned for data "{0}". Expected: "{1}", got: "{2}"'.format(hexEncoded, bytesRead, resultNumBytesRead)) 156 | 157 | def test_encodeAddressField(self): 158 | for plaintext, bytesRead, hexEncoded, realHexEncoded in self.tests: 159 | expected = bytearray(codecs.decode(realHexEncoded, 'hex_codec')) 160 | result = gsmmodem.pdu._encodeAddressField(plaintext) 161 | self.assertEqual(result, expected, 'Failed to encode address field data "{0}". Expected: "{1}", got: "{2}"'.format(plaintext, realHexEncoded, codecs.encode(compat.str(result), 'hex_codec').upper())) 162 | 163 | class TestSmsPduSmscFields(unittest.TestCase): 164 | """ Tests for SMS PDU SMSC-specific address fields (these methods are not meant to be public) 165 | 166 | Note: SMSC fields are encoded *slightly* differently from "normal" address fields (the length indicator is different) 167 | """ 168 | 169 | def setUp(self): 170 | self.tests = (('+9876543210', 7, b'06918967452301', b'06918967452301'), 171 | ('+9876543210', 7, b'06918967452301000000', b'06918967452301'), # same as above, but checking read limits 172 | ('+987654321', 7, b'069189674523F1000000', b'069189674523F1'), 173 | ('+2782913593', 7, b'06917228195339', b'06917228195339')) 174 | 175 | def test_decodeSmscField(self): 176 | for plaintext, bytesRead, hexEncoded, realHexEncoded in self.tests: 177 | byteIter = iter(bytearray(codecs.decode(hexEncoded, 'hex_codec'))) 178 | resultValue, resultNumBytesRead = gsmmodem.pdu._decodeAddressField(byteIter, smscField=True) 179 | self.assertEqual(resultValue, plaintext, 'Failed to decode SMSC address field data "{0}". Expected: "{1}", got: "{2}"'.format(hexEncoded, plaintext, resultValue)) 180 | self.assertEqual(resultNumBytesRead, bytesRead, 'Incorrect "number of bytes read" returned for data "{0}". Expected: "{1}", got: "{2}"'.format(hexEncoded, bytesRead, resultNumBytesRead)) 181 | 182 | def test_encodeSmscField(self): 183 | for plaintext, bytesRead, hexEncoded, realHexEncoded in self.tests: 184 | expected = bytearray(codecs.decode(realHexEncoded, 'hex_codec')) 185 | result = gsmmodem.pdu._encodeAddressField(plaintext, smscField=True) 186 | self.assertEqual(result, expected, 'Failed to encode SMSC address field data "{0}". Expected: "{1}", got: "{2}"'.format(plaintext, realHexEncoded, codecs.encode(compat.str(result), 'hex_codec').upper())) 187 | 188 | 189 | class TestRelativeValidityPeriod(unittest.TestCase): 190 | """ Tests for SMS PDU relative validity period encoding/decoding (these methods are not meant to be public) """ 191 | 192 | def setUp(self): 193 | self.tests = ((timedelta(minutes=30), 5), 194 | (timedelta(hours=16), 151), 195 | (timedelta(days=3), 169), 196 | (timedelta(weeks=5), 197)) 197 | 198 | def test_encode(self): 199 | for validity, tpVp in self.tests: 200 | result = gsmmodem.pdu._encodeRelativeValidityPeriod(validity) 201 | self.assertEqual(result, tpVp, 'Failed to encode relative validity period: {0}. Expected: "{1}", got: "{2}"'.format(validity, tpVp, result)) 202 | self.assertIsInstance(result, tpVp.__class__, 'Invalid data type returned; expected {0}, got {1}'.format(tpVp.__class__, result.__class__)) 203 | 204 | def test_decode(self): 205 | for validity, tpVp in self.tests: 206 | result = gsmmodem.pdu._decodeRelativeValidityPeriod(tpVp) 207 | self.assertEqual(result, validity, 'Failed to decode relative validity period: {0}. Expected: "{1}", got: "{2}"'.format(tpVp, validity, result)) 208 | 209 | def test_decode_invalidTpVp(self): 210 | tpVp = 2048 # invalid since > 255 211 | self.assertRaises(ValueError, gsmmodem.pdu._decodeRelativeValidityPeriod, tpVp) 212 | 213 | def test_encode_validityPeriodTooLong(self): 214 | validity = timedelta(weeks=1000) 215 | self.assertRaises(ValueError, gsmmodem.pdu._encodeRelativeValidityPeriod, validity) 216 | 217 | 218 | class TestTimestamp(unittest.TestCase): 219 | """ Tests for SMS PDU timestamp encoding used for absolute validity period encoding/decoding (these methods are not meant to be public) """ 220 | 221 | def setUp(self): 222 | self.tests = ((datetime(2015, 11, 27, 0, 0, 0, tzinfo=SimpleOffsetTzInfo(0)), b'51117200000000'), 223 | (datetime(2015, 11, 27, 0, 0, 0, tzinfo=SimpleOffsetTzInfo(2)), b'51117200000080'), # same as previous but with GMT+2 timezone 224 | (datetime(2007, 4, 12, 23, 25, 42, tzinfo=SimpleOffsetTzInfo(8)), b'70402132522423'), 225 | (datetime(2007, 4, 12, 23, 25, 42, tzinfo=SimpleOffsetTzInfo(-8)), b'7040213252242B'), # same as previous but with GMT-8 timezone 226 | ) 227 | 228 | def test_encode(self): 229 | for timestamp, encodedHex in self.tests: 230 | encoded = bytearray(codecs.decode(encodedHex, 'hex_codec')) 231 | result = gsmmodem.pdu._encodeTimestamp(timestamp) 232 | self.assertEqual(result, encoded, 'Failed to encode timestamp: {0}. Expected: "{1}", got: "{2}"'.format(timestamp, encodedHex, codecs.encode(compat.str(result), 'hex_codec').upper())) 233 | 234 | def test_decode(self): 235 | for timestamp, encoded in self.tests: 236 | result = gsmmodem.pdu._decodeTimestamp(encoded) 237 | self.assertEqual(result, timestamp, 'Failed to decode timestamp: {0}. Expected: "{1}", got: "{2}"'.format(encoded, timestamp, result)) 238 | 239 | def test_encode_noTimezone(self): 240 | """ Tests encoding without timezone information """ 241 | timestamp = datetime(2013, 3, 1, 12, 30, 21) 242 | self.assertRaises(ValueError, gsmmodem.pdu._encodeTimestamp, timestamp) 243 | 244 | 245 | class TestSmsPduTzInfo(unittest.TestCase): 246 | """ Basic tests for the SmsPduTzInfo class """ 247 | 248 | def test_pickle(self): 249 | """ Ensure SmsPduTzInfo objects can be pickled (mentioneded as requirement of tzinfo implementations in Python docs) """ 250 | import pickle 251 | obj = gsmmodem.pdu.SmsPduTzInfo('08') 252 | self.assertIsInstance(obj, gsmmodem.pdu.SmsPduTzInfo) 253 | pickledObj = pickle.dumps(obj) 254 | self.assertNotEqual(obj, pickledObj) 255 | unpickledObj = pickle.loads(pickledObj) 256 | self.assertIsInstance(unpickledObj, gsmmodem.pdu.SmsPduTzInfo) 257 | self.assertEqual(obj.utcoffset(0), unpickledObj.utcoffset(0)) 258 | 259 | def test_dst(self): 260 | """ Test SmsPduTzInfo.dst() """ 261 | obj = gsmmodem.pdu.SmsPduTzInfo('08') 262 | self.assertEqual(obj.dst(0), timedelta(0)) 263 | 264 | def test_utcoffset(self): 265 | """ Test SmsPduTzInfo.utcoffest() """ 266 | tests = (('08', 2), ('B2', -8)) 267 | for pduOffsetStr, offset in tests: 268 | result = gsmmodem.pdu.SmsPduTzInfo(pduOffsetStr) 269 | expected = SimpleOffsetTzInfo(offset) 270 | self.assertEqual(result.utcoffset(0), expected.utcoffset(0)) 271 | 272 | 273 | class TestUdhConcatenation(unittest.TestCase): 274 | """ Tests for UDH concatenation information element """ 275 | 276 | def setUp(self): 277 | self.tests = ((23, 1, 3, b'0003170301'), # 8-bit reference 278 | (384, 2, 4, b'080401800402') # 16-bit reference 279 | ) 280 | 281 | def test_encode(self): 282 | for ref, number, parts, ieHex in self.tests: 283 | concatIe = gsmmodem.pdu.Concatenation() 284 | concatIe.reference = ref 285 | concatIe.number = number 286 | concatIe.parts = parts 287 | expected = bytearray(codecs.decode(ieHex, 'hex_codec')) 288 | result = concatIe.encode() 289 | self.assertEqual(result, expected, 'Failed to encode Concatenation Information Element; expected: "{0}", got: "{1}"'.format(ieHex, codecs.encode(compat.str(result), 'hex_codec').upper())) 290 | # Now modify some values and ensure encoded values changes 291 | concatIe.reference = ref+1 292 | result = concatIe.encode() 293 | self.assertNotEqual(result, expected, 'Modifications to UDH information element object not reflected in encode()') 294 | 295 | def test_decode(self): 296 | for ref, number, parts, ieHex in self.tests: 297 | ieData = bytearray(codecs.decode(ieHex, 'hex_codec')) 298 | # Test IE constructor with args 299 | result = gsmmodem.pdu.InformationElement(ieData[0], ieData[1], ieData[2:]) 300 | self.assertIsInstance(result, gsmmodem.pdu.Concatenation, 'Invalid object type returned; expected Concatenation, got {0}'.format(type(result))) 301 | self.assertEqual(result.reference, ref, 'Invalid reference; expected {0}, got {1}'.format(ref, result.reference)) 302 | self.assertEqual(result.number, number, 'Invalid part number; expected {0}, got {1}'.format(number, result.number)) 303 | self.assertEqual(result.parts, parts, 'Invalid total number of parts; expected {0}, got {1}'.format(parts, result.parts)) 304 | # Test IE constructor with kwargs 305 | result = gsmmodem.pdu.InformationElement(iei=ieData[0], ieLen=ieData[1], ieData=ieData[2:]) 306 | self.assertIsInstance(result, gsmmodem.pdu.Concatenation, 'Invalid object type returned; expected Concatenation, got {0}'.format(type(result))) 307 | self.assertEqual(result.reference, ref, 'Invalid reference; expected {0}, got {1}'.format(ref, result.reference)) 308 | self.assertEqual(result.number, number, 'Invalid part number; expected {0}, got {1}'.format(number, result.number)) 309 | self.assertEqual(result.parts, parts, 'Invalid total number of parts; expected {0}, got {1}'.format(parts, result.parts)) 310 | 311 | 312 | class TestUdhPortAddress(unittest.TestCase): 313 | """ Tests for UDH application port addressing scheme information element """ 314 | 315 | def setUp(self): 316 | self.tests = ((100, 50, b'04026432'), # 8-bit addresses 317 | (1234, 5222, b'050404D21466') # 16-bit addresses 318 | ) 319 | 320 | def test_encode(self): 321 | for destination, source, ieHex in self.tests: 322 | portIe = gsmmodem.pdu.PortAddress() 323 | portIe.source = source 324 | portIe.destination = destination 325 | expected = bytearray(codecs.decode(ieHex, 'hex_codec')) 326 | result = portIe.encode() 327 | self.assertEqual(result, expected, 'Failed to encode PortAddress Information Element; expected: "{0}", got: "{1}"'.format(ieHex, codecs.encode(compat.str(result), 'hex_codec').upper())) 328 | # Now modify some values and ensure encoded values changes 329 | portIe.destination = destination+1 330 | result = portIe.encode() 331 | self.assertNotEqual(result, expected, 'Modifications to UDH information element object not reflected in encode()') 332 | 333 | def test_decode(self): 334 | for destination, source, ieHex in self.tests: 335 | ieData = bytearray(codecs.decode(ieHex, 'hex_codec')) 336 | # Test IE constructor with args 337 | result = gsmmodem.pdu.InformationElement(ieData[0], ieData[1], ieData[2:]) 338 | self.assertIsInstance(result, gsmmodem.pdu.PortAddress, 'Invalid object type returned; expected Concatenation, got {0}'.format(type(result))) 339 | self.assertEqual(result.source, source, 'Invalid origin port number; expected {0}, got {1}'.format(source, result.source)) 340 | self.assertEqual(result.destination, destination, 'Invalid destination port number; expected {0}, got {1}'.format(destination, result.destination)) 341 | # Test IE constructor with kwargs 342 | result = gsmmodem.pdu.InformationElement(iei=ieData[0], ieLen=ieData[1], ieData=ieData[2:]) 343 | self.assertIsInstance(result, gsmmodem.pdu.PortAddress, 'Invalid object type returned; expected Concatenation, got {0}'.format(type(result))) 344 | self.assertEqual(result.source, source, 'Invalid origin port number; expected {0}, got {1}'.format(source, result.source)) 345 | self.assertEqual(result.destination, destination, 'Invalid destination port number; expected {0}, got {1}'.format(destination, result.destination)) 346 | 347 | class TestSmsPdu(unittest.TestCase): 348 | """ Tests encoding/decoding of SMS PDUs """ 349 | 350 | def test_encodeSmsSubmit(self): 351 | """ Tests SMS PDU encoding """ 352 | tests = (('+27820001111', 'Hello World!', 0, None, None, False, False, b'0001000B917228001011F100000CC8329BFD065DDF72363904'), 353 | ('+27820001111', 'Flash SMS', 0, None, None, False, True, b'0005000B917228001011F10000094676788E064D9B53'), 354 | ('+123456789', '世界您好!', 0, timedelta(weeks=52), '+44000000000', False, False, b'07914400000000F01100099121436587F90008F40A4E16754C60A8597DFF01'), 355 | ('0126541234', 'Test message: local numbers', 13, timedelta(days=3), '12345', True, False, b'04A12143F5310D0AA110624521430000A91BD4F29C0E6A97E7F3F0B9AC03B1DFE3301BE4AEB7C565F91C'), 356 | ('+27820001111', 'Timestamp validity test', 0, datetime(2013, 7, 10, 13, 39, tzinfo=SimpleOffsetTzInfo(2)), None, False, False, b'0019000B917228001011F100003170013193008017D474BB3CA787DB70903DCC4E93D3F43C885E9ED301'), 357 | ) 358 | for number, text, reference, validity, smsc, rejectDuplicates, sendFlash, pduHex in tests: 359 | pdu = bytearray(codecs.decode(pduHex, 'hex_codec')) 360 | result = gsmmodem.pdu.encodeSmsSubmitPdu(number, text, reference, validity, smsc, rejectDuplicates, sendFlash) 361 | self.assertIsInstance(result, list) 362 | self.assertEqual(len(result), 1, 'Only 1 PDU should have been created, but got {0}'.format(len(result))) 363 | self.assertIsInstance(result[0], gsmmodem.pdu.Pdu) 364 | self.assertEqual(result[0].data, pdu, 'Failed to encode SMS PDU for number: "{0}" and text "{1}". Expected: "{2}", got: "{3}"'.format(number, text, pduHex, codecs.encode(compat.str(result[0].data), 'hex_codec').upper())) 365 | 366 | def test_decode(self): 367 | """ Tests SMS PDU decoding """ 368 | tests = ((b'06917228195339040B917228214365F700003130805120618005D4F29C2E03', {'type': 'SMS-DELIVER', 369 | 'smsc': '+2782913593', 370 | 'number': '+27821234567', 371 | 'protocol_id': 0, 372 | 'time': datetime(2013, 3, 8, 15, 2, 16, tzinfo=SimpleOffsetTzInfo(2)), 373 | 'text': 'Test2'}), 374 | (b'07915892000000F0040B915892214365F700007040213252242331493A283D0795C3F33C88FE06C9CB6132885EC6D341EDF27C1E3E97E7207B3A0C0A5241E377BB1D7693E72E', 375 | {'type': 'SMS-DELIVER', 376 | 'smsc': '+85290000000', 377 | 'number': '+85291234567', 378 | 'time': datetime(2007, 4, 12, 23, 25, 42, tzinfo=SimpleOffsetTzInfo(8)), 379 | 'text': 'It is easy to read text messages via AT commands.'}), 380 | (b'06917228195339040B917228214365F70000313062315352800A800D8A5E98D337A910', 381 | {'type': 'SMS-DELIVER', 382 | 'number': '+27821234567', 383 | 'text': '@{tést}!'}), 384 | (b'07911326040000F0310D0B911326880736F40000A90FF7FBDD454E87CDE1B0DB357EB701', 385 | {'type': 'SMS-SUBMIT', 386 | 'smsc': '+31624000000', 387 | 'number': '+31628870634', 388 | 'validity': timedelta(days=3), 389 | 'text': 'www.diafaan.com'}), 390 | (b'0006D60B911326880736F4111011719551401110117195714000', 391 | {'type': 'SMS-STATUS-REPORT', 392 | 'number': '+31628870634', 393 | 'reference': 214}), 394 | (b'0591721891F1400781721881F800003160526104848059050003C30101916536FB1DCABEEB2074D85E064941B19CAB060319A5C522289C96D3D3ED32286C0FA7D96131BBEC024941B19CAB0603DDD36C36A88C87A7E565D0DB0D82C55EB0DB4B068BCD5C20', 395 | {'type': 'SMS-DELIVER', 396 | 'number': '2781188', 397 | 'smsc': '+2781191', 398 | 'text': 'Hello!You have R 19.50 FREE airtime available. R 19.50 will expire on 01/07/2013. ', 399 | 'udh': [gsmmodem.pdu.Concatenation(0x00, 0x03, [0xC3, 0x01, 0x01])]}), 400 | (b'07914346466554F601000B914316565811F9000806304253F68449', # Valid UCS-2 PDU 401 | {'type': 'SMS-SUBMIT', 402 | 'number': '+34616585119', 403 | 'smsc': '+34646456456', 404 | 'text': 'あ叶葉'}), 405 | (b'0041010C910661345542F60008A0050003000301306F3044', # UCS-2 PDU; User data length is invalid in this PDU (too long) 406 | {'type': 'SMS-SUBMIT', 407 | 'number': '+60164355246', 408 | 'smsc': None, 409 | 'udh': [gsmmodem.pdu.Concatenation(0x00, 0x03, [0x00, 0x03, 0x01])], 410 | 'text': 'はい'}), 411 | (b'0591721891F101000B917228214365F700040C48656C6C6F20776F726C6421', # 8-bit data coding 412 | {'type': 'SMS-SUBMIT', 413 | 'number': '+27821234567', 414 | 'smsc': '+2781191', 415 | 'text': 'Hello world!'}), 416 | (b'0019000B917228001011F100003170013193008017D474BB3CA787DB70903DCC4E93D3F43C885E9ED301', # absolute validity period 417 | {'text': 'Timestamp validity test', 418 | 'validity': datetime(2013, 7, 10, 13, 39, tzinfo=SimpleOffsetTzInfo(2))}), 419 | # Semi-invalid status report PDU captured from a ZTE modem 420 | (b'0297F1061C0F910B487228297020F5317062419272803170624192138000', 421 | {'type': 'SMS-STATUS-REPORT', 422 | 'number': '+b08427829207025', # <- broken number (invalid PDU data; the reference number is more than a single byte (or they added something)) 423 | 'reference': 28, 424 | 'time': datetime(2013, 7, 26, 14, 29, 27, tzinfo=SimpleOffsetTzInfo(2)), 425 | 'discharge': datetime(2013, 7, 26, 14, 29, 31, tzinfo=SimpleOffsetTzInfo(2))}), 426 | (b'07919762020033F1400DD0CDF2396C7EBB010008415072411084618C0500035602010053004D005300200063006F00640065003A00200034003800350036002C00200063006F006E006600690072006D006100740069006F006E0020006F00660020006100730073006F00630069006100740069006F006E0020006200650074007700650065006E0020006100630063006F0075006E007400200061006E00640020004D00650067', 427 | {'type': 'SMS-DELIVER', 428 | 'smsc': '+79262000331', 429 | 'number': 'Megafon', 430 | 'text': 'SMS code: 4856, confirmation of association between account and Meg', 431 | 'time': datetime(2014, 5, 27, 14, 1, 48, tzinfo=SimpleOffsetTzInfo(4))}) 432 | ) 433 | 434 | for pdu, expected in tests: 435 | result = gsmmodem.pdu.decodeSmsPdu(pdu) 436 | self.assertIsInstance(result, dict) 437 | for key, value in expected.items(): 438 | self.assertIn(key, result) 439 | if key == 'udh': 440 | self.assertEqual(len(result[key]), len(value), 'Incorrect number of UDH information elements; expected {0}, got {1}'.format(len(result[key]), len(value))) 441 | for i in range(len(value)): 442 | got = result[key][i] 443 | expected = value[i] 444 | self.assertIsInstance(got, expected.__class__) 445 | self.assertEqual(expected.id, got.id) 446 | self.assertEqual(expected.dataLength, got.dataLength) 447 | self.assertEqual(expected.data, got.data) 448 | if isinstance(expected, gsmmodem.pdu.Concatenation): 449 | self.assertEqual(got.reference, expected.reference) 450 | self.assertEqual(got.parts, expected.parts) 451 | self.assertEqual(got.number, expected.number) 452 | elif isinstance(expected, gsmmodem.pdu.PortAddress): 453 | self.assertEqual(got.destination, expected.destination) 454 | self.assertEqual(got.source, expected.source) 455 | else: 456 | self.assertEqual(result[key], value, 'Failed to decode PDU value for "{0}". Expected "{1}", got "{2}".'.format(key, value, result[key])) 457 | 458 | def test_encodeSmsSubmit_concatenated(self): 459 | """ Tests concatenated SMS encoding """ 460 | tests = (('Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.Ut enim ad minim veniam, quinostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.Duis aute irure dolor in reprehenderit in voluptate velit esse cillum doloe eu fugiat nulla pariatur.Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum', 461 | '+15125551234', 462 | [b'0045000B915121551532F40000A0050003000301986F79B90D4AC3E7F53688FC66BFE5A0799A0E0AB7CB741668FC76CFCB637A995E9783C2E4343C3D4F8FD3EE33A8CC4ED359A079990C22BF41E5747DDE7E9341F4721BFE9683D2EE719A9C26D7DD74509D0E6287C56F791954A683C86FF65B5E06B5C36777181466A7E3F5B0AB4A0795DDE936284C06B5D3EE741B642FBBD3E1360B14AFA7DD', 463 | b'0045000B915121551532F40000A0050003000302DE73BABC4E0695F165F9384D0FD3D36F37A8CE6687DBE337881D16BFE5E939C89D9EA741753A28CC4EC7EB6938A88C0795C3A0F1BBDD7E93DFA0F1DB3D2FC7EB61BA8B584FCF41E13ABD0C4ACBEBF23288FC66BFE5A0B41B242FC3E56574D94D2ECBD37450DA0DB2BFD975383D4C2F83EC65769A0E2ACFE765D038CD66D7DB20F29BFD2E83CA', 464 | b'0045000B915121551532F400008C050003000303EA2073FD9C0ED341EE3A9B1D06C1C3F274985E97BB8AF871194E2FD7E5A079DA4D07BDC7E370791CA683C675789A1CA687E920F7DB0D82CBDF6972D94D6781E675371D947683C675363C0C8AD7D3A0B7D99C1EA7C32072795E96D7DD7450FBCD66A7E9A0B03BDD06A5C9A0F29C0E6287C56F79BD0D'] 465 | ),) 466 | for text, number, hexPdus in tests: 467 | result = gsmmodem.pdu.encodeSmsSubmitPdu(number, text, reference=0, requestStatusReport=False, rejectDuplicates=True) 468 | self.assertIsInstance(result, list) 469 | self.assertEqual(len(result), len(hexPdus), 'Invalid number of PDUs returned; expected {0}, got {1}'.format(len(hexPdus), len(result))) 470 | i = 0 471 | for pdu in result: 472 | self.assertIsInstance(pdu, gsmmodem.pdu.Pdu) 473 | expectedPduHex = hexPdus[i] 474 | expectedPdu = bytearray(codecs.decode(expectedPduHex, 'hex_codec')) 475 | self.assertEqual(pdu.data, expectedPdu, 'Failed to encode concatentated SMS PDU (PDU {0}/{1}). Expected: "{2}", got: "{3}"'.format(i+1, len(result), expectedPduHex, codecs.encode(compat.str(pdu.data), 'hex_codec').upper())) 476 | i += 1 477 | 478 | def test_encodeSmsSubmit_invalidValidityType(self): 479 | """ Tests SMS PDU encoding when specifying an invalid object type for validity """ 480 | self.assertRaises(TypeError, gsmmodem.pdu.encodeSmsSubmitPdu, **{'number': '123', 'text': 'abc', 'validity': 'INVALID'}) 481 | 482 | def test_decode_invalidPduType(self): 483 | """ Tests SMS PDU decoding when an invalid PDU type is specified """ 484 | # PDU first octect: 0x43; thus PDU type: 0x03 (invalid) 485 | pdu = '0043010C910661345542F60008A0050003000301306F3044' 486 | self.assertRaises(gsmmodem.exceptions.EncodingError, gsmmodem.pdu.decodeSmsPdu, pdu) 487 | 488 | def test_decode_invalidData(self): 489 | """ Tests SMS PDU decoding when completely invalid data is specified """ 490 | pdu = 'AFSDSDF LJJFKLDJKLFJ# #$KJLKJL SF' 491 | self.assertRaises(gsmmodem.exceptions.EncodingError, gsmmodem.pdu.decodeSmsPdu, pdu) 492 | pdu = 'AEFDSDFSDFSDFS' 493 | self.assertRaises(gsmmodem.exceptions.EncodingError, gsmmodem.pdu.decodeSmsPdu, pdu) 494 | 495 | 496 | if __name__ == "__main__": 497 | unittest.main() -------------------------------------------------------------------------------- /test/test_serial_comms.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ Test suite for gsmmodem.serial_comms """ 4 | 5 | from __future__ import print_function 6 | 7 | import sys, time, unittest, logging 8 | from copy import copy 9 | 10 | from . import compat # For Python 2.6 compatibility 11 | 12 | import gsmmodem.serial_comms 13 | from gsmmodem.exceptions import TimeoutException 14 | 15 | class MockSerialPackage(object): 16 | """ Fake serial package for the GsmModem/SerialComms classes to import during tests """ 17 | 18 | class Serial(): 19 | 20 | _REPONSE_TIME = 0.02 21 | 22 | """ Mock serial object for use by the GsmModem class during tests """ 23 | def __init__(self, *args, **kwargs): 24 | # The default value to read/"return" if responseSequence isn't set up, or None for nothing 25 | #self.defaultResponse = 'OK\r\n' 26 | self.responseSequence = [] 27 | self.flushResponseSequence = True 28 | self.writeQueue = [] 29 | self._alive = True 30 | self._readQueue = [] 31 | self.writeCallbackFunc = None 32 | 33 | def read(self, timeout=None): 34 | if len(self._readQueue) > 0: 35 | return self._readQueue.pop(0) 36 | elif len(self.writeQueue) > 0: 37 | self._setupReadValue(self.writeQueue.pop(0)) 38 | if len(self._readQueue) > 0: 39 | return self._readQueue.pop(0) 40 | elif self.flushResponseSequence and len(self.responseSequence) > 0: 41 | self._setupReadValue(None) 42 | 43 | if timeout != None: 44 | time.sleep(0.001) 45 | # time.sleep(min(timeout, self._REPONSE_TIME)) 46 | # if timeout > self._REPONSE_TIME and len(self.writeQueue) == 0: 47 | # time.sleep(timeout - self._REPONSE_TIME) 48 | return '' 49 | else: 50 | while self._alive: 51 | if len(self.writeQueue) > 0: 52 | self._setupReadValue(self.writeQueue.pop(0)) 53 | if len(self._readQueue) > 0: 54 | return self._readQueue.pop(0) 55 | # time.sleep(self._REPONSE_TIME) 56 | time.sleep(0.05) 57 | 58 | def _setupReadValue(self, command): 59 | if len(self._readQueue) == 0: 60 | if len(self.responseSequence) > 0: 61 | value = self.responseSequence.pop(0) 62 | if type(value) in (float, int): 63 | time.sleep(value) 64 | if len(self.responseSequence) > 0: 65 | self._setupReadValue(command) 66 | else: 67 | self._readQueue = list(value) 68 | 69 | def write(self, data): 70 | if self.writeCallbackFunc != None: 71 | self.writeCallbackFunc(data) 72 | self.writeQueue.append(data) 73 | 74 | def close(self): 75 | pass 76 | 77 | def inWaiting(self): 78 | rqLen = len(self._readQueue) 79 | for item in self.responseSequence: 80 | if type(item) in (int, float): 81 | break 82 | else: 83 | rqLen += len(item) 84 | return rqLen 85 | 86 | 87 | class SerialException(Exception): 88 | """ Mock Serial Exception """ 89 | 90 | class TestNotifications(unittest.TestCase): 91 | """ Tests reading unsolicited notifications from the serial devices """ 92 | 93 | def setUp(self): 94 | self.mockSerial = MockSerialPackage() 95 | gsmmodem.serial_comms.serial = self.mockSerial 96 | self.tests = (['ABC\r\n'], 97 | [' blah blah blah \r\n', '12345\r\n']) 98 | 99 | def test_callback(self): 100 | """ Tests if the notification callback method is correctly called """ 101 | for test in self.tests: 102 | callbackCalled = [False] 103 | def callback(data): 104 | callbackCalled[0] = [True] 105 | self.assertIsInstance(data, list) 106 | self.assertEqual(len(data), len(test)) 107 | for i in range(len(test)): 108 | self.assertEqual(data[i], test[i][:-2]) 109 | 110 | serialComms = gsmmodem.serial_comms.SerialComms('-- PORT IGNORED DURING TESTS --', notifyCallbackFunc=callback) 111 | serialComms.connect() 112 | # Fake a notification 113 | serialComms.serial.responseSequence = copy(test) 114 | # Wait a bit for the event to be picked up 115 | while len(serialComms.serial._readQueue) > 0 or len(serialComms.serial.responseSequence) > 0: 116 | time.sleep(0.05) 117 | self.assertTrue(callbackCalled[0], 'Notification callback function not called') 118 | serialComms.close() 119 | 120 | def test_noCallback(self): 121 | """ Tests notifications when no callback method was specified (nothing should happen) """ 122 | for test in self.tests: 123 | serialComms = gsmmodem.serial_comms.SerialComms('-- PORT IGNORED DURING TESTS --') 124 | serialComms.connect() 125 | # Fake a notification 126 | serialComms.serial.responseSequence = copy(test) 127 | # Wait a bit for the event to be picked up 128 | while len(serialComms.serial._readQueue) > 0 or len(serialComms.serial.responseSequence) > 0: 129 | time.sleep(0.05) 130 | serialComms.close() 131 | 132 | class TestSerialException(unittest.TestCase): 133 | """ Tests SerialException handling """ 134 | 135 | def setUp(self): 136 | self.mockSerial = MockSerialPackage() 137 | gsmmodem.serial_comms.serial = self.mockSerial 138 | self.serialComms = gsmmodem.serial_comms.SerialComms('-- PORT IGNORED DURING TESTS --') 139 | self.serialComms.connect() 140 | 141 | def tearDown(self): 142 | self.serialComms.close() 143 | 144 | def test_readLoopException(self): 145 | """ Tests handling a SerialException from inside the read loop thread """ 146 | self.assertTrue(self.serialComms.alive) 147 | exceptionRaised = [False] 148 | callbackCalled = [False] 149 | 150 | def brokenRead(*args, **kwargs): 151 | exceptionRaised[0] = True 152 | raise MockSerialPackage.SerialException() 153 | self.serialComms.serial.read = brokenRead 154 | 155 | def errorCallback(ex): 156 | callbackCalled[0] = True 157 | self.assertIsInstance(ex, MockSerialPackage.SerialException) 158 | self.serialComms.fatalErrorCallback = errorCallback 159 | 160 | # Let the serial comms object attempt to read something 161 | self.serialComms.serial.responseSequence = ['12345\r\n'] 162 | while not exceptionRaised[0]: 163 | time.sleep(0.05) 164 | self.assertFalse(self.serialComms.alive) 165 | time.sleep(0.05) 166 | self.assertTrue(callbackCalled[0], 'Error callback not called on fatal error') 167 | 168 | 169 | class TestWrite(unittest.TestCase): 170 | """ Tests writing to the serial device """ 171 | 172 | def setUp(self): 173 | self.mockSerial = MockSerialPackage() 174 | gsmmodem.serial_comms.serial = self.mockSerial 175 | self.serialComms = gsmmodem.serial_comms.SerialComms('-- PORT IGNORED DURING TESTS --') 176 | self.serialComms.connect() 177 | 178 | def tearDown(self): 179 | self.serialComms.close() 180 | 181 | def test_write(self): 182 | """ Tests basic writing operations """ 183 | tests = ((['OK\r\n'], ['OK']), 184 | (['ERROR\r\n'], ['ERROR']), 185 | (['first line\r\n', 'second line\r\n', 'OK\r\n'], ['first line', 'second line', 'OK']), 186 | # Some Huawei modems issue this response instead of ERROR for unknown commands; ensure we detect it correctly 187 | (['COMMAND NOT SUPPORT\r\n'], ['COMMAND NOT SUPPORT'])) 188 | for actual, expected in tests: 189 | self.serialComms.serial.responseSequence = actual 190 | self.serialComms.serial.flushResponseSequence = True 191 | response = self.serialComms.write('test\r') 192 | self.assertEqual(response, expected) 193 | # Now write without expecting a response 194 | response = self.serialComms.write('test2\r', waitForResponse=False) 195 | self.assertEqual(response, None) 196 | 197 | def test_writeTimeout(self): 198 | """ Tests that the serial comms write timeout parameter """ 199 | # Serial comms will not response (no response sequence specified) 200 | self.assertRaises(TimeoutException, self.serialComms.write, 'test\r', waitForResponse=True, timeout=0.1) 201 | 202 | def test_writeTimeout_data(self): 203 | """ Tests passing partial data along with a TimeoutException """ 204 | self.serialComms.serial.responseSequence = ['abc\r\n', 0.5, 'def\r\n'] 205 | self.serialComms.serial.flushResponseSequence = True 206 | try: 207 | self.serialComms.write('test\r', waitForResponse=True, timeout=0.1) 208 | except TimeoutException as timeout: 209 | # The 0.5s pause in the response should cause the write to timeout but still return the first part 210 | self.assertEqual(timeout.data, ['abc']) 211 | else: 212 | self.fail('TimeoutException not thrown') 213 | 214 | def test_writeTimeout_noData(self): 215 | """ Similar to test_writeTimeout(), but checks TimeoutException's data field is None """ 216 | try: 217 | self.serialComms.write('test\r', waitForResponse=True, timeout=0.1) 218 | except TimeoutException as timeout: 219 | self.assertEqual(timeout.data, None) 220 | else: 221 | self.fail('TimeoutException not thrown') 222 | 223 | 224 | if __name__ == "__main__": 225 | logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.DEBUG) 226 | unittest.main() 227 | -------------------------------------------------------------------------------- /test/test_util.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ Test suite for gsmmodem.util """ 4 | 5 | from __future__ import print_function 6 | 7 | import sys, time, unittest, logging, re 8 | from datetime import timedelta 9 | 10 | from . import compat # For Python 2.6 compatibility 11 | 12 | from gsmmodem.util import allLinesMatchingPattern, lineMatching, lineStartingWith, lineMatchingPattern, SimpleOffsetTzInfo 13 | 14 | class TestUtil(unittest.TestCase): 15 | """ Tests misc utilities from gsmmodem.util """ 16 | 17 | def test_lineStartingWith(self): 18 | """ Tests function: lineStartingWith """ 19 | lines = ['12345', 'abc', 'defghi', 'abcdef', 'efg'] 20 | result = lineStartingWith('abc', lines) 21 | self.assertEqual(result, 'abc') 22 | result = lineStartingWith('d', lines) 23 | self.assertEqual(result, 'defghi') 24 | result = lineStartingWith('zzz', lines) 25 | self.assertEqual(result, None) 26 | 27 | def test_lineMatching(self): 28 | """ Tests function: lineMatching """ 29 | lines = ['12345', 'abc', 'defghi', 'abcdef', 'efg'] 30 | result = lineMatching('^abc.*$', lines) 31 | self.assertEqual(result.string, 'abc') 32 | result = lineMatching('^\d+$', lines) 33 | self.assertEqual(result.string, '12345') 34 | result = lineMatching('^ZZZ\d+$', lines) 35 | self.assertEqual(result, None) 36 | 37 | def test_lineMatchingPattern(self): 38 | """ Tests function: lineMatchingPattern """ 39 | lines = ['12345', 'abc', 'defghi', 'abcdef', 'efg'] 40 | result = lineMatchingPattern(re.compile('^abc.*$'), lines) 41 | self.assertEqual(result.string, 'abc') 42 | result = lineMatchingPattern(re.compile('^\d+$'), lines) 43 | self.assertEqual(result.string, '12345') 44 | result = lineMatchingPattern(re.compile('^ZZZ\d+$'), lines) 45 | self.assertEqual(result, None) 46 | 47 | def test_allLinesMatchingPattern(self): 48 | """ Tests function: lineStartingWith """ 49 | lines = ['12345', 'abc', 'defghi', 'abcdef', 'efg'] 50 | result = allLinesMatchingPattern(re.compile('^abc.*$'), lines) 51 | self.assertIsInstance(result, list) 52 | self.assertEqual(len(result), 2) 53 | self.assertEqual(result[0].string, 'abc') 54 | self.assertEqual(result[1].string, 'abcdef') 55 | result = allLinesMatchingPattern(re.compile('^defghi$'), lines) 56 | self.assertIsInstance(result, list) 57 | self.assertEqual(len(result), 1) 58 | self.assertEqual(result[0].string, 'defghi') 59 | result = allLinesMatchingPattern(re.compile('^\d+$'), lines) 60 | self.assertIsInstance(result, list) 61 | self.assertEqual(len(result), 1) 62 | self.assertEqual(result[0].string, '12345') 63 | result = allLinesMatchingPattern(re.compile('^ZZZ\d+$'), lines) 64 | self.assertIsInstance(result, list) 65 | self.assertEqual(result, []) 66 | 67 | def test_SimpleOffsetTzInfo(self): 68 | """ Basic test for the SimpleOffsetTzInfo class """ 69 | tests = (2, -4, 0, 3.5) 70 | for hours in tests: 71 | tz = SimpleOffsetTzInfo(hours) 72 | self.assertEqual(tz.offsetInHours, hours) 73 | self.assertEqual(tz.utcoffset(None), timedelta(hours=hours)) 74 | self.assertEqual(tz.dst(None), timedelta(0)) 75 | self.assertIsInstance(tz.__repr__(), str) 76 | 77 | 78 | if __name__ == "__main__": 79 | logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.DEBUG) 80 | unittest.main() 81 | -------------------------------------------------------------------------------- /tools/at_cmd_init_modem.txt: -------------------------------------------------------------------------------- 1 | # Simple script for GSMTerm to initialize the modem with a few common settings 2 | # Load this from within GSMTerm by typing: load /path/to/at_cmd_init_modem.txt 3 | ATZ 4 | ATE0 5 | AT+CFUN=1 6 | AT+CMEE=1 7 | AT+WIND=0 8 | 9 | # Network-related settings 10 | AT+COPS=3,0 11 | 12 | # Setup incoming calls 13 | AT+CLIP=1 14 | AT+CRC=1 15 | AT+CVHU=0 16 | 17 | # Setup SMS 18 | AT+CMGF=1 19 | AT+CSMP=49,167,0,0 20 | AT+CPMS="ME","ME","ME" 21 | AT+CNMI=2,1,0,2 22 | -------------------------------------------------------------------------------- /tools/gsmterm.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """\ 4 | Launch script for GSMTerm 5 | 6 | @author: Francois Aucamp 7 | """ 8 | from __future__ import print_function 9 | 10 | import sys 11 | 12 | from gsmtermlib.terminal import GsmTerm, RawTerm 13 | 14 | def parseArgs(): 15 | """ Argument parser for Python 2.7 and above """ 16 | from argparse import ArgumentParser 17 | parser = ArgumentParser(description='User-friendly terminal for interacting with a connected GSM modem.') 18 | parser.add_argument('port', metavar='PORT', help='port to which the GSM modem is connected; a number or a device name.') 19 | parser.add_argument('-b', '--baud', metavar='BAUDRATE', default=115200, help='set baud rate') 20 | parser.add_argument('-r', '--raw', action='store_true', help='switch to raw terminal mode') 21 | return parser.parse_args() 22 | 23 | def parseArgsPy26(): 24 | """ Argument parser for Python 2.6 """ 25 | from gsmtermlib.posoptparse import PosOptionParser, Option 26 | parser = PosOptionParser(description='User-friendly terminal for interacting with a connected GSM modem.') 27 | parser.add_positional_argument(Option('--port', metavar='PORT', help='port to which the GSM modem is connected; a number or a device name.')) 28 | parser.add_option('-b', '--baud', metavar='BAUDRATE', default=115200, help='set baud rate') 29 | parser.add_option('-r', '--raw', action='store_true', help='switch to raw terminal mode') 30 | options, args = parser.parse_args() 31 | if len(args) != 1: 32 | parser.error('Incorrect number of arguments - please specify a PORT to connect to, e.g. {0} /dev/ttyUSB0'.format(sys.argv[0])) 33 | else: 34 | options.port = args[0] 35 | return options 36 | 37 | def main(): 38 | args = parseArgsPy26() if sys.version_info[0] == 2 and sys.version_info[1] < 7 else parseArgs() 39 | if args.raw: 40 | gsmTerm = RawTerm(args.port, args.baud) 41 | else: 42 | gsmTerm = GsmTerm(args.port, args.baud) 43 | 44 | gsmTerm.start() 45 | gsmTerm.rxThread.join() 46 | print('Done.') 47 | 48 | if __name__ == '__main__': 49 | main() 50 | 51 | -------------------------------------------------------------------------------- /tools/gsmtermlib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/faucamp/python-gsmmodem/834c68b1387ca2c91e2210faa8f75526b39723b5/tools/gsmtermlib/__init__.py -------------------------------------------------------------------------------- /tools/gsmtermlib/posoptparse.py: -------------------------------------------------------------------------------- 1 | """ PosOptionParser class gotten from Douglas Mayle at StackOverflow: 2 | http://stackoverflow.com/a/664614/1980416 3 | 4 | Used for positional argument support similar to argparse (for Python 2.6 compatibility) 5 | """ 6 | 7 | from optparse import OptionParser, Option, IndentedHelpFormatter 8 | 9 | class PosOptionParser(OptionParser): 10 | def format_help(self, formatter=None): 11 | class Positional(object): 12 | def __init__(self, args): 13 | self.option_groups = [] 14 | self.option_list = args 15 | 16 | positional = Positional(self.positional) 17 | formatter = IndentedHelpFormatter() 18 | formatter.store_option_strings(positional) 19 | output = ['\n', formatter.format_heading("Positional Arguments")] 20 | formatter.indent() 21 | pos_help = [formatter.format_option(opt) for opt in self.positional] 22 | pos_help = [line.replace('--','') for line in pos_help] 23 | output += pos_help 24 | return OptionParser.format_help(self, formatter) + ''.join(output) 25 | 26 | def add_positional_argument(self, option): 27 | try: 28 | args = self.positional 29 | except AttributeError: 30 | args = [] 31 | args.append(option) 32 | self.positional = args 33 | 34 | def set_out(self, out): 35 | self.out = out 36 | -------------------------------------------------------------------------------- /tools/gsmtermlib/terminal.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """\ 4 | GSMTerm: A user-friendly terminal for interacting with a GSM modem 5 | 6 | Note: The "Console" object was copied from pySerial's miniterm.py code 7 | 8 | @author: Francois Aucamp 9 | """ 10 | 11 | from __future__ import print_function 12 | import os, sys, threading, time 13 | import serial 14 | 15 | from gsmmodem.serial_comms import SerialComms 16 | from .trie import Trie 17 | from gsmmodem.exceptions import TimeoutException 18 | 19 | # first choose a platform dependant way to read single characters from the console 20 | global console 21 | 22 | if os.name == 'nt': 23 | import msvcrt 24 | class Console(object): 25 | 26 | CURSOR_UP = '{0}{1}'.format(chr(0xe0), chr(0x48)) 27 | CURSOR_DOWN = '{0}{1}'.format(chr(0xe0), chr(0x50)) 28 | CURSOR_LEFT = '{0}{1}'.format(chr(0xe0), chr(0x4b)) 29 | CURSOR_RIGHT = '{0}{1}'.format(chr(0xe0), chr(0x4d)) 30 | #TODO: find out what this in windows: 31 | DELETE = '' 32 | HOME = '' 33 | END = '' 34 | 35 | def __init__(self): 36 | pass 37 | 38 | def setup(self): 39 | pass # Do nothing for 'nt' 40 | 41 | def cleanup(self): 42 | pass # Do nothing for 'nt' 43 | 44 | def getkey(self): 45 | while True: 46 | z = msvcrt.getch() 47 | if z == '\xe0': # extended (cursor keys, etc) 48 | z += msvcrt.getch() 49 | return z 50 | elif z == '\0': # functions keys, ignore 51 | msvcrt.getch() 52 | else: 53 | if z == '\r': 54 | return '\n' 55 | return z 56 | 57 | console = Console() 58 | 59 | elif os.name == 'posix': 60 | import termios, tty 61 | class Console(object): 62 | 63 | CURSOR_UP = '{0}{1}{2}'.format(chr(27), chr(91), chr(65)) 64 | CURSOR_DOWN = '{0}{1}{2}'.format(chr(27), chr(91), chr(66)) 65 | CURSOR_LEFT = '{0}{1}{2}'.format(chr(27), chr(91), chr(68)) 66 | CURSOR_RIGHT = '{0}{1}{2}'.format(chr(27), chr(91), chr(67)) 67 | DELETE = '{0}{1}{2}{3}'.format(chr(27), chr(91), chr(51), chr(126)) 68 | HOME = '{0}{1}{2}'.format(chr(27), chr(79), chr(72)) 69 | END = '{0}{1}{2}'.format(chr(27), chr(79), chr(70)) 70 | 71 | def __init__(self): 72 | self.fd = sys.stdin.fileno() 73 | 74 | def setup(self): 75 | self.old = termios.tcgetattr(self.fd) 76 | new = termios.tcgetattr(self.fd) 77 | new[3] = new[3] & ~termios.ICANON & ~termios.ECHO & ~termios.ISIG 78 | new[6][termios.VMIN] = 1 79 | new[6][termios.VTIME] = 0 80 | termios.tcsetattr(self.fd, termios.TCSANOW, new) 81 | 82 | # def setup(self): 83 | # self.oldSettings = termios.tcgetattr(self.fd) 84 | # tty.setraw(self.fd) 85 | 86 | def getkey(self): 87 | c = os.read(self.fd, 4) 88 | #print (len(c)) 89 | #for a in c: 90 | # print('rx:',ord(a)) 91 | return c 92 | 93 | def cleanup(self): 94 | termios.tcsetattr(self.fd, termios.TCSAFLUSH, self.old) 95 | 96 | console = Console() 97 | 98 | def cleanup_console(): 99 | console.cleanup() 100 | 101 | console.setup() 102 | sys.exitfunc = cleanup_console # terminal modes have to be restored on exit... 103 | 104 | else: 105 | raise NotImplementedError("Sorry no implementation for your platform (%s) available." % sys.platform) 106 | 107 | 108 | class RawTerm(SerialComms): 109 | """ "Raw" terminal - basically just copies console input to serial, and prints out anything read """ 110 | 111 | EXIT_CHARACTER = '\x1d' # CTRL+] 112 | WRITE_TERM = '\r' # Write terminator character 113 | 114 | def __init__(self, port, baudrate=9600): 115 | super(RawTerm, self).__init__(port, baudrate, notifyCallbackFunc=self._handleModemNotification) 116 | self.port = port 117 | self.baudrate = baudrate 118 | self.echo = True 119 | 120 | def _handleModemNotification(self, lines): 121 | for line in lines: 122 | print(line) 123 | 124 | def printStartMessage(self): 125 | print('\nRaw terminal connected to {0} at {1}bps.\nPress CTRL+] to exit.\n'.format(self.port, self.baudrate)) 126 | 127 | def start(self): 128 | self.connect() 129 | # Start input thread 130 | self.alive = True 131 | self.inputThread = threading.Thread(target=self._inputLoop) 132 | self.inputThread.daemon = True 133 | self.inputThread.start() 134 | self.printStartMessage() 135 | 136 | def stop(self): 137 | self.alive = False 138 | if threading.current_thread() != self.inputThread: 139 | self.inputThread.join() 140 | self.close() 141 | 142 | def _inputLoop(self): 143 | """ Loop and copy console->serial until EXIT_CHARCTER character is found. """ 144 | try: 145 | while self.alive: 146 | try: 147 | c = console.getkey() 148 | except KeyboardInterrupt: 149 | print('kbint') 150 | c = serial.to_bytes([3]) 151 | if c == self.EXIT_CHARACTER: 152 | self.stop() 153 | elif c == '\n': 154 | # Convert newline input into \r 155 | self.serial.write(self.WRITE_TERM) 156 | if self.echo: 157 | # Locally just echo the real newline 158 | sys.stdout.write(c) 159 | sys.stdout.flush() 160 | else: 161 | #print('writing: ', c) 162 | self.serial.write(c) 163 | if self.echo: 164 | sys.stdout.write(c) 165 | sys.stdout.flush() 166 | except: 167 | self.alive = False 168 | raise 169 | 170 | class GsmTerm(RawTerm): 171 | """ User-friendly terminal for interacting with a GSM modem. 172 | 173 | Some features: tab-completion, help 174 | """ 175 | 176 | PROMPT = 'GSM> ' 177 | SMS_PROMPT = '> ' 178 | EXIT_CHARACTER_2 = chr(4) # CTRL+D 179 | 180 | BACKSPACE_CHARACTER = chr(127) 181 | CTRL_Z_CHARACTER = chr(26) # Used when entering SMS messages with AT+CMGS 182 | ESC_CHARACTER = chr(27) # Used to cancel entering SMS messages with AT+CMGS 183 | 184 | RESET_SEQ = '\033[0m' 185 | COLOR_SEQ = '\033[1;{0}m' 186 | BOLD_SEQ = '\033[1m' 187 | 188 | # ANSI colour escapes 189 | COLOR_RED = COLOR_SEQ.format(30+1) 190 | COLOR_GREEN = COLOR_SEQ.format(30+2) 191 | COLOR_YELLOW = COLOR_SEQ.format(30+3) 192 | COLOR_BLUE = COLOR_SEQ.format(30+4) 193 | COLOR_MAGENTA = COLOR_SEQ.format(30+5) 194 | COLOR_WHITE = COLOR_SEQ.format(30+7) 195 | COLOR_CYAN = COLOR_SEQ.format(30+6) 196 | 197 | def __init__(self, port, baudrate=9600, useColor=True): 198 | super(GsmTerm, self).__init__(port, baudrate) 199 | self.inputBuffer = [] 200 | self.history = [] 201 | self.historyPos = 0 202 | self.useColor = useColor 203 | self.cursorPos = 0 204 | if self.useColor: 205 | self.PROMPT = self._color(self.COLOR_GREEN, self.PROMPT) 206 | self.SMS_PROMPT = self._color(self.COLOR_GREEN, self.SMS_PROMPT) 207 | self._initAtCommandsTrie() 208 | # Flag that indicates whether the user is typing an SMS message's text 209 | self._typingSms = False 210 | 211 | def printStartMessage(self): 212 | # self.stdscr.addstr('GSMTerm started. Press CTRL+] to exit.') 213 | print('\nGSMTerm connected to {0} at {1}bps.\nPress CTRL+] or CTRL+D to exit.\n'.format(self.port, self.baudrate)) 214 | self._refreshInputPrompt() 215 | 216 | def _color(self, color, msg): 217 | """ Converts a message to be printed to the user's terminal in red """ 218 | if self.useColor: 219 | return '{0}{1}{2}'.format(color, msg, self.RESET_SEQ) 220 | else: 221 | return msg 222 | 223 | def _boldFace(self, msg): 224 | """ Converts a message to be printed to the user's terminal in bold """ 225 | return self._color(self.BOLD_SEQ, msg) 226 | 227 | def _handleModemNotification(self, lines): 228 | # Clear any input prompt 229 | self._removeInputPrompt() 230 | if self._typingSms: 231 | self.PROMPT = self._color(self.COLOR_CYAN, lines[0]) 232 | if lines[-1] == 'ERROR': 233 | print(self._color(self.COLOR_RED, '\n'.join(lines))) 234 | else: 235 | print(self._color(self.COLOR_CYAN, '\n'.join(lines))) 236 | self._refreshInputPrompt() 237 | 238 | def _addToHistory(self, command): 239 | self.history.append(command) 240 | if len(self.history) > 100: 241 | self.history = self.history[1:] 242 | 243 | def _inputLoop(self): 244 | """ Loop and copy console->serial until EXIT_CHARCTER character is found. """ 245 | 246 | # Switch statement for handling "special" characters 247 | actionChars = {self.EXIT_CHARACTER: self._exit, 248 | self.EXIT_CHARACTER_2: self._exit, 249 | 250 | console.CURSOR_LEFT: self._cursorLeft, 251 | console.CURSOR_RIGHT: self._cursorRight, 252 | console.CURSOR_UP: self._cursorUp, 253 | console.CURSOR_DOWN: self._cursorDown, 254 | 255 | '\n': self._doConfirmInput, 256 | '\t': self._doCommandCompletion, 257 | 258 | self.CTRL_Z_CHARACTER: self._handleCtrlZ, 259 | self.ESC_CHARACTER: self._handleEsc, 260 | 261 | self.BACKSPACE_CHARACTER: self._handleBackspace, 262 | console.DELETE: self._handleDelete, 263 | console.HOME: self._handleHome, 264 | console.END: self._handleEnd} 265 | 266 | try: 267 | while self.alive: 268 | try: 269 | c = console.getkey() 270 | except KeyboardInterrupt: 271 | c = serial.to_bytes([3]) 272 | if c in actionChars: 273 | # Handle character directly 274 | actionChars[c]() 275 | elif len(c) == 1 and self._isPrintable(c): 276 | self.inputBuffer.insert(self.cursorPos, c) 277 | self.cursorPos += 1 278 | self._refreshInputPrompt() 279 | #else: 280 | # for a in c: 281 | # print('GOT:',a,'(',ord(a),')') 282 | except: 283 | self.alive = False 284 | raise 285 | 286 | def _handleCtrlZ(self): 287 | """ Handler for CTRL+Z keypresses """ 288 | if self._typingSms: 289 | self.serial.write(''.join(self.inputBuffer)) 290 | self.serial.write(self.CTRL_Z_CHARACTER) 291 | self._typingSms = False 292 | self.inputBuffer = [] 293 | self.cursorPos = 0 294 | sys.stdout.write('\n') 295 | self._refreshInputPrompt() 296 | 297 | def _handleEsc(self): 298 | """ Handler for CTRL+Z keypresses """ 299 | if self._typingSms: 300 | self.serial.write(self.ESC_CHARACTER) 301 | self._typingSms = False 302 | self.inputBuffer = [] 303 | self.cursorPos = 0 304 | 305 | def _exit(self): 306 | """ Shuts down the terminal (and app) """ 307 | self._removeInputPrompt() 308 | print(self._color(self.COLOR_YELLOW, 'CLOSING TERMINAL...')) 309 | self.stop() 310 | 311 | def _cursorLeft(self): 312 | """ Handles "cursor left" events """ 313 | if self.cursorPos > 0: 314 | self.cursorPos -= 1 315 | sys.stdout.write(console.CURSOR_LEFT) 316 | sys.stdout.flush() 317 | 318 | def _cursorRight(self): 319 | """ Handles "cursor right" events """ 320 | if self.cursorPos < len(self.inputBuffer): 321 | self.cursorPos += 1 322 | sys.stdout.write(console.CURSOR_RIGHT) 323 | sys.stdout.flush() 324 | 325 | def _cursorUp(self): 326 | """ Handles "cursor up" events """ 327 | if self.historyPos > 0: 328 | self.historyPos -= 1 329 | clearLen = len(self.inputBuffer) 330 | self.inputBuffer = list(self.history[self.historyPos]) 331 | self.cursorPos = len(self.inputBuffer) 332 | self._refreshInputPrompt(clearLen) 333 | 334 | def _cursorDown(self): 335 | """ Handles "cursor down" events """ 336 | if self.historyPos < len(self.history)-1: 337 | clearLen = len(self.inputBuffer) 338 | self.historyPos += 1 339 | self.inputBuffer = list(self.history[self.historyPos]) 340 | self.cursorPos = len(self.inputBuffer) 341 | self._refreshInputPrompt(clearLen) 342 | 343 | def _handleBackspace(self): 344 | """ Handles backspace characters """ 345 | if self.cursorPos > 0: 346 | #print( 'cp:',self.cursorPos,'was:', self.inputBuffer) 347 | self.inputBuffer = self.inputBuffer[0:self.cursorPos-1] + self.inputBuffer[self.cursorPos:] 348 | self.cursorPos -= 1 349 | #print ('cp:', self.cursorPos,'is:', self.inputBuffer) 350 | self._refreshInputPrompt(len(self.inputBuffer)+1) 351 | 352 | def _handleDelete(self): 353 | """ Handles "delete" characters """ 354 | if self.cursorPos < len(self.inputBuffer): 355 | self.inputBuffer = self.inputBuffer[0:self.cursorPos] + self.inputBuffer[self.cursorPos+1:] 356 | self._refreshInputPrompt(len(self.inputBuffer)+1) 357 | 358 | def _handleHome(self): 359 | """ Handles "home" character """ 360 | self.cursorPos = 0 361 | self._refreshInputPrompt(len(self.inputBuffer)) 362 | 363 | def _handleEnd(self): 364 | """ Handles "end" character """ 365 | self.cursorPos = len(self.inputBuffer) 366 | self._refreshInputPrompt(len(self.inputBuffer)) 367 | 368 | def _doConfirmInput(self): 369 | if self._typingSms: 370 | # SMS messages are confirmed with CTRL+Z or canceled with ESC 371 | inputStr = ''.join(self.inputBuffer[:self.cursorPos]) 372 | self.serial.write(inputStr) 373 | self.inputBuffer = self.inputBuffer[self.cursorPos:] 374 | self.cursorPos = 0 375 | sys.stdout.write('\n') 376 | self._refreshInputPrompt() 377 | return 378 | # Convert newline input into \r\n 379 | if len(self.inputBuffer) > 0: 380 | inputStr = ''.join(self.inputBuffer).strip() 381 | self.inputBuffer = [] 382 | self.cursorPos = 0 383 | inputStrLen = len(inputStr) 384 | if len(inputStr) > 0: 385 | self._addToHistory(inputStr) 386 | self.historyPos = len(self.history) 387 | if inputStrLen > 2: 388 | if inputStr[0] == '?': # ?COMMAND 389 | # Help requested with function 390 | self._printCommandHelp(inputStr[1:]) 391 | return 392 | elif inputStr[-1] == inputStr[-2] == '?': # COMMAND?? 393 | # Help requested with function 394 | cmd = inputStr[:-3 if inputStr[-3] == '=' else -2] 395 | self._printCommandHelp(cmd) 396 | return 397 | inputStrLower = inputStr.lower() 398 | if inputStrLower.startswith('help'): # help COMMAND 399 | # Alternative help invocation 400 | self._printCommandHelp(inputStr[5:]) 401 | return 402 | elif inputStrLower.startswith('ls'): 403 | if inputStrLower == 'lscat': 404 | sys.stdout.write('\n') 405 | for category in self.completion.categories: 406 | sys.stdout.write('{0}\n'.format(category)) 407 | self._refreshInputPrompt(len(self.inputBuffer)) 408 | return 409 | elif inputStrLower == 'ls': 410 | sys.stdout.write('\n') 411 | for command in self.completion: 412 | sys.stdout.write('{0:<8} - {1}\n'.format(command, self.completion[command][1])) 413 | self._refreshInputPrompt(len(self.inputBuffer)) 414 | return 415 | else: 416 | ls = inputStrLower.split(' ', 1) 417 | if len(ls) == 2: 418 | category = ls[1].lower() 419 | if category in [cat.lower() for cat in self.completion.categories]: 420 | sys.stdout.write('\n') 421 | for command in self.completion: 422 | commandHelp = self.completion[command] 423 | if category == commandHelp[0].lower(): 424 | sys.stdout.write('{0:<8} - {1}\n'.format(command, commandHelp[1])) 425 | self._refreshInputPrompt(len(self.inputBuffer)) 426 | return 427 | elif inputStrLower.startswith('load'): 428 | # Load a file containing AT commands to issue 429 | load = inputStr.split(' ', 1) 430 | if len(load) == 2: 431 | filename = load[1].strip() 432 | try: 433 | f = open(filename, 'r') 434 | except IOError: 435 | sys.stdout.write('\n{0}\n'.format(self._color(self.COLOR_RED, 'File not found: "{0}"'.format(filename)))) 436 | self._refreshInputPrompt(len(self.inputBuffer)) 437 | else: 438 | atCommands = f.readlines() 439 | f.close() 440 | sys.stdout.write('\n') 441 | for atCommand in atCommands: 442 | atCommand = atCommand.strip() 443 | if len(atCommand) > 0 and atCommand[0] != '#': 444 | self.inputBuffer = list(atCommand.strip()) 445 | self._refreshInputPrompt(len(self.inputBuffer)) 446 | self._doConfirmInput() 447 | time.sleep(0.1) 448 | return 449 | if len(inputStr) > 0: 450 | if inputStrLower.startswith('at+cmgs='): 451 | # Prepare for SMS input 452 | self._typingSms = True 453 | try: 454 | sys.stdout.write('\n') 455 | sys.stdout.flush() 456 | response = self.write(inputStr + self.WRITE_TERM, waitForResponse=True, timeout=3, expectedResponseTermSeq='> ') 457 | except TimeoutException: 458 | self._typingSms = False 459 | else: 460 | sys.stdout.write(self._color(self.COLOR_YELLOW, 'Type your SMS message, and press CTRL+Z to send it or press ESC to cancel.\n')) 461 | self.SMS_PROMPT = self._color(self.COLOR_GREEN, response[0]) 462 | self._refreshInputPrompt() 463 | return 464 | self.serial.write(inputStr) 465 | self.serial.write(self.WRITE_TERM) 466 | # Locally just echo the real newline 467 | sys.stdout.write('\n') 468 | sys.stdout.flush() 469 | 470 | def _printGeneralHelp(self): 471 | sys.stdout.write(self._color(self.COLOR_WHITE, '\n\n== GSMTerm Help ==\n\n')) 472 | sys.stdout.write('{0} Press the up & down arrow keys to move backwards or forwards through your command history.\n\n'.format(self._color(self.COLOR_YELLOW, 'Command History:'))) 473 | sys.stdout.write('{0} Press the TAB key to provide command completion suggestions. Press the TAB key after a command is fully typed (with or without a "=" character) to quickly see its syntax.\n\n'.format(self._color(self.COLOR_YELLOW, 'Command Completion:'))) 474 | sys.stdout.write('{0} Type a command, followed with two quesetion marks to access its documentation, e.g. "??". Alternatively, precede the command with a question mark ("?"), or type "help ".\n\n'.format(self._color(self.COLOR_YELLOW, 'Command Documentation:'))) 475 | sys.stdout.write('{0} Type "ls [category]" to list the available AT commands known to GSMTerm for the given category (or all commands if no category is specified).\nType "lscat" to see a list of categories.\n\n'.format(self._color(self.COLOR_YELLOW, 'List Available Commands:'))) 476 | sys.stdout.write('{0} Type "load " to load and execute a file containing AT commands, separated by newlines, e.g. "load ./myscript.txt".\n\n'.format(self._color(self.COLOR_YELLOW, 'Load Script:'))) 477 | sys.stdout.write('To exit GSMTerm, press CTRL+] or CTRL+D.\n\n') 478 | self._refreshInputPrompt(len(self.inputBuffer)) 479 | 480 | def _printCommandHelp(self, command=None): 481 | if command == None or len(command.strip()) == 0: 482 | # Print general help 483 | self._printGeneralHelp() 484 | return 485 | try: 486 | command = command.strip() 487 | commandHelp = self.completion[command.upper()] 488 | except KeyError: 489 | noHelp = True 490 | else: 491 | noHelp = commandHelp == None 492 | if noHelp: 493 | sys.stdout.write('\r No help available for: {0}\n'.format(self._color(self.COLOR_WHITE, command))) 494 | else: 495 | sys.stdout.write('\n\n{0} ({1})\n\n'.format(self._color(self.COLOR_WHITE, commandHelp[1]), command)) 496 | sys.stdout.write('{0} {1}\n'.format(self._color(self.COLOR_YELLOW, 'Category:'), commandHelp[0])) 497 | if len(commandHelp) == 2: 498 | sys.stdout.write('\nNo detailed help available for this command.\n\n') 499 | self._refreshInputPrompt(len(self.inputBuffer)) 500 | return 501 | sys.stdout.write('{0} {1}\n'.format(self._color(self.COLOR_YELLOW, 'Description:'), commandHelp[4])) 502 | 503 | valuesIsEnum = len(commandHelp) >= 6 504 | if valuesIsEnum: # "Values" is an enum of allowed values (not multiple variables); use custom label 505 | sys.stdout.write('{0} '.format(self._color(self.COLOR_YELLOW, commandHelp[5]))) 506 | else: 507 | sys.stdout.write('{0} '.format(self._color(self.COLOR_YELLOW, 'Values:'))) 508 | commandValues = commandHelp[2] 509 | syntax = [self._color(self.COLOR_WHITE, command)] 510 | if commandValues != None: 511 | if '+' in command or command.upper() in ['ATS0']: 512 | syntax.append(self._color(self.COLOR_WHITE, '=')) 513 | sys.stdout.write('\n') 514 | first = True 515 | for value, valueDesc in commandValues: 516 | if first: 517 | first = False 518 | else: 519 | syntax.append(',' if not valuesIsEnum else '|') 520 | syntax.append(self._color(self.COLOR_MAGENTA, value)) 521 | sys.stdout.write(' {0} {1}\n'.format(self._color(self.COLOR_MAGENTA, value), valueDesc.replace('\n', '\n' + ' ' * (len(value) + 2)) if valueDesc != None else '')) 522 | else: 523 | sys.stdout.write('No parameters.\n') 524 | returnValues = commandHelp[3] 525 | if returnValues != None: 526 | sys.stdout.write('{0} '.format(self._color(self.COLOR_YELLOW, 'Response Values:'))) 527 | sys.stdout.write('\n') 528 | for value, valueDesc in returnValues: 529 | sys.stdout.write(' {0} {1}\n'.format(self._color(self.COLOR_CYAN, value), valueDesc.replace('\n', '\n' + ' ' * (len(value) + 2)) if valueDesc != None else '')) 530 | sys.stdout.write('{0}\n {1}\n\n'.format(self._color(self.COLOR_YELLOW, 'Command Syntax:'), ''.join(syntax))) 531 | self._refreshInputPrompt(len(self.inputBuffer)) 532 | 533 | def _doCommandCompletion(self): 534 | """ Command-completion method """ 535 | prefix = ''.join(self.inputBuffer).strip().upper() 536 | matches = self.completion.keys(prefix) 537 | matchLen = len(matches) 538 | if matchLen == 0 and prefix[-1] == '=': 539 | try: 540 | command = prefix[:-1] 541 | except KeyError: 542 | pass 543 | else: 544 | self.__printCommandSyntax(command) 545 | elif matchLen > 0: 546 | if matchLen == 1: 547 | if matches[0] == prefix: 548 | # User has already entered command - show command syntax 549 | self.__printCommandSyntax(prefix) 550 | else: 551 | # Complete only possible command 552 | self.inputBuffer = list(matches[0]) 553 | self.cursorPos = len(self.inputBuffer) 554 | self._refreshInputPrompt(len(self.inputBuffer)) 555 | return 556 | else: 557 | commonPrefix = self.completion.longestCommonPrefix(''.join(self.inputBuffer)) 558 | self.inputBuffer = list(commonPrefix) 559 | self.cursorPos = len(self.inputBuffer) 560 | if matchLen > 20: 561 | matches = matches[:20] 562 | matches.append('... ({0} more)'.format(matchLen - 20)) 563 | sys.stdout.write('\n') 564 | for match in matches: 565 | sys.stdout.write(' {0} '.format(match)) 566 | sys.stdout.write('\n') 567 | sys.stdout.flush() 568 | self._refreshInputPrompt(len(self.inputBuffer)) 569 | 570 | def __printCommandSyntax(self, command): 571 | """ Command-completion helper method: print command syntax """ 572 | commandHelp = self.completion[command] 573 | if commandHelp != None and len(commandHelp) > 2: 574 | commandValues = commandHelp[2] 575 | #commandDefault = commandHelp[3] 576 | displayHelp = [self._color(self.COLOR_WHITE, command)] 577 | if commandValues != None: 578 | valuesIsEnum = len(commandHelp) >= 6 579 | if '+' in command or command.upper() in ['ATS0']: 580 | displayHelp.append(self._color(self.COLOR_WHITE, '=')) 581 | displayHelp.append(('|' if valuesIsEnum else ',').join([value[0] for value in commandValues])) 582 | sys.stdout.write('\r Syntax: {0}\n'.format(self._color(self.COLOR_WHITE, ''.join(displayHelp)))) 583 | sys.stdout.flush() 584 | self._refreshInputPrompt(len(self.inputBuffer)) 585 | 586 | def _isPrintable(self, char): 587 | return 33 <= ord(char) <= 126 or char.isspace() 588 | 589 | def _refreshInputPrompt(self, clearLen=0): 590 | termPrompt = self.SMS_PROMPT if self._typingSms else self.PROMPT 591 | endPoint = clearLen if clearLen > 0 else len(self.inputBuffer) 592 | sys.stdout.write('\r{0}{1}{2}{3}'.format(termPrompt, ''.join(self.inputBuffer), (clearLen - len(self.inputBuffer)) * ' ', console.CURSOR_LEFT * (endPoint - self.cursorPos))) 593 | sys.stdout.flush() 594 | 595 | def _removeInputPrompt(self): 596 | termPrompt = self.SMS_PROMPT if self._typingSms else self.PROMPT 597 | sys.stdout.write('\r{0}\r'.format(' ' * (len(termPrompt) + len(self.inputBuffer)))) 598 | 599 | def _initAtCommandsTrie(self): 600 | self.completion = Trie() 601 | from .atcommands import ATCOMMANDS, CATEGORIES 602 | for command, help in ATCOMMANDS: 603 | if help != None: 604 | self.completion[command] = help 605 | else: 606 | self.completion[command] = None 607 | self.completion.categories = CATEGORIES 608 | 609 | -------------------------------------------------------------------------------- /tools/gsmtermlib/trie.py: -------------------------------------------------------------------------------- 1 | """ Pure Python trie implementation for strings """ 2 | 3 | # Compensate for differences between Python 2 and 3 4 | import sys 5 | if sys.version_info[0] >= 3: 6 | dictKeysIter = dict.keys 7 | dictItemsIter = dict.items 8 | dictValuesIter = dict.values 9 | else: #pragma: no cover 10 | dictKeysIter = dict.iterkeys 11 | dictItemsIter = dict.iteritems 12 | dictValuesIter = dict.itervalues 13 | 14 | 15 | class Trie(object): 16 | 17 | def __init__(self, key=None, value=None): 18 | self.slots = {} 19 | self.key = key 20 | self.value = value 21 | 22 | def __setitem__(self, key, value): 23 | if key == None: 24 | raise ValueError('Key may not be None') 25 | 26 | if len(key) == 0: 27 | # All of the original key's chars have been nibbled away 28 | self.value = value 29 | self.key = '' 30 | return 31 | 32 | c = key[0] 33 | 34 | if c not in self.slots: 35 | # Unused slot - no collision 36 | if self.key != None and len(self.key) > 0: 37 | # This was a "leaf" previously - create a new branch for its current value 38 | branchC = self.key[0] 39 | branchKey = self.key[1:] if len(self.key) > 1 else '' 40 | self.slots[branchC] = Trie(branchKey, self.value) 41 | self.key = None 42 | self.value = None 43 | if branchC != c: 44 | self.slots[c] = Trie(key[1:], value) 45 | else: 46 | self.slots[c][key[1:]] = value 47 | else: 48 | # Store specified value in a new branch and return 49 | self.slots[c] = Trie(key[1:], value) 50 | else: 51 | trie = self.slots[c] 52 | trie[key[1:]] = value 53 | 54 | 55 | def __delitem__(self, key): 56 | if key == None: 57 | raise ValueError('Key may not be None') 58 | if len(key) == 0: 59 | if self.key == '': 60 | self.key = None 61 | self.value = None 62 | return 63 | else: 64 | raise KeyError(key) 65 | c = key[0] 66 | if c in self.slots: 67 | trie = self.slots[c] 68 | if key[1:] == trie.key: 69 | if len(trie.slots) > 0: 70 | trie.key = None 71 | trie.value = None 72 | else: 73 | del self.slots[c] # Remove the node 74 | else: 75 | del trie[key[1:]] 76 | else: 77 | raise KeyError(key) 78 | 79 | def __getitem__(self, key): 80 | if key == None: 81 | raise ValueError('Key may not be None') 82 | if len(key) == 0: 83 | if self.key == '': 84 | # All of the original key's chars have ben nibbled away 85 | return self.value 86 | else: 87 | raise KeyError(key) 88 | c = key[0] 89 | if c in self.slots: 90 | trie = self.slots[c] 91 | return trie[key[1:]] 92 | elif key == self.key: 93 | return self.value 94 | else: 95 | raise KeyError(key) 96 | 97 | def __contains__(self, key): 98 | try: 99 | self.__getitem__(key) 100 | except KeyError: 101 | return False 102 | return True 103 | 104 | def __len__(self): 105 | global dictValuesIter 106 | n = 1 if self.key != None else 0 107 | for trie in dictValuesIter(self.slots): 108 | n += len(trie) 109 | return n 110 | 111 | def get(self, key, default=None): 112 | try: 113 | return self.__getitem__(key) 114 | except KeyError: 115 | return default 116 | 117 | def _allKeys(self, prefix): 118 | """ Private implementation method. Use keys() instead. """ 119 | global dictItemsIter 120 | result = [prefix + self.key] if self.key != None else [] 121 | for key, trie in dictItemsIter(self.slots): 122 | result.extend(trie._allKeys(prefix + key)) 123 | return result 124 | 125 | def keys(self, prefix=None): 126 | """ Return all or possible keys in this trie 127 | 128 | If prefix is None, return all keys. 129 | If prefix is a string, return all keys that start with this string 130 | """ 131 | if prefix == None: 132 | return self._allKeys('') 133 | else: 134 | return self._filteredKeys(prefix, '') 135 | 136 | def _filteredKeys(self, key, prefix): 137 | global dictKeysIter 138 | global dictItemsIter 139 | if len(key) == 0: 140 | result = [prefix + self.key] if self.key != None else [] 141 | for c, trie in dictItemsIter(self.slots): 142 | result.extend(trie._allKeys(prefix + c)) 143 | else: 144 | c = key[0] 145 | if c in dictKeysIter(self.slots): 146 | result = [] 147 | trie = self.slots[c] 148 | result.extend(trie._filteredKeys(key[1:], prefix+c)) 149 | else: 150 | result = [prefix + self.key] if self.key != None and self.key.startswith(key) else [] 151 | return result 152 | 153 | def longestCommonPrefix(self, prefix=''): 154 | """ Return the longest common prefix shared by all keys that start with prefix 155 | (note: the return value will always start with the specified prefix) 156 | """ 157 | return self._longestCommonPrefix(prefix, '') 158 | 159 | def _longestCommonPrefix(self, key, prefix): 160 | if len(key) == 0: 161 | if self.key != None: 162 | return prefix + self.key 163 | else: 164 | slotKeys = list(self.slots.keys()) 165 | if len(slotKeys) == 1: 166 | c = slotKeys[0] 167 | return self.slots[c]._longestCommonPrefix('', prefix + c) 168 | else: 169 | return prefix 170 | elif self.key != None: 171 | if self.key.startswith(key): 172 | return prefix + self.key 173 | else: 174 | return '' # nothing starts with the specified prefix 175 | else: 176 | c = key[0] 177 | if c in self.slots: 178 | return self.slots[c]._longestCommonPrefix(key[1:], prefix + c) 179 | else: 180 | return '' # nothing starts with the specified prefix 181 | 182 | def __iter__(self): 183 | for k in list(self.keys()): 184 | yield k 185 | raise StopIteration 186 | -------------------------------------------------------------------------------- /tools/identify-modem.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | 4 | """\ 5 | Simple script to assist with identifying a GSM modem 6 | The debug information obtained by this script (when using -d) can be used 7 | to aid test cases (since I don't have access to every modem in the world ;-) ) 8 | 9 | @author: Francois Aucamp 10 | """ 11 | from __future__ import print_function 12 | import sys 13 | 14 | from gsmmodem.modem import GsmModem 15 | from gsmmodem.exceptions import TimeoutException, PinRequiredError, IncorrectPinError 16 | 17 | def parseArgs(): 18 | """ Argument parser for Python 2.7 and above """ 19 | from argparse import ArgumentParser 20 | parser = ArgumentParser(description='Identify and debug attached GSM modem') 21 | parser.add_argument('port', metavar='PORT', help='port to which the GSM modem is connected; a number or a device name.') 22 | parser.add_argument('-b', '--baud', metavar='BAUDRATE', default=115200, help='set baud rate') 23 | parser.add_argument('-p', '--pin', metavar='PIN', default=None, help='SIM card PIN') 24 | parser.add_argument('-d', '--debug', action='store_true', help='dump modem debug information (for python-gsmmodem development)') 25 | return parser.parse_args() 26 | 27 | def parseArgsPy26(): 28 | """ Argument parser for Python 2.6 """ 29 | from gsmtermlib.posoptparse import PosOptionParser, Option 30 | parser = PosOptionParser(description='Identify and debug attached GSM modem') 31 | parser.add_positional_argument(Option('--port', metavar='PORT', help='port to which the GSM modem is connected; a number or a device name.')) 32 | parser.add_option('-b', '--baud', metavar='BAUDRATE', default=115200, help='set baud rate') 33 | parser.add_option('-p', '--pin', metavar='PIN', default=None, help='SIM card PIN') 34 | parser.add_option('-d', '--debug', action='store_true', help='dump modem debug information (for python-gsmmodem development)') 35 | options, args = parser.parse_args() 36 | if len(args) != 1: 37 | parser.error('Incorrect number of arguments - please specify a PORT to connect to, e.g. {0} /dev/ttyUSB0'.format(sys.argv[0])) 38 | else: 39 | options.port = args[0] 40 | return options 41 | 42 | def main(): 43 | args = parseArgsPy26() if sys.version_info[0] == 2 and sys.version_info[1] < 7 else parseArgs() 44 | print ('args:',args) 45 | modem = GsmModem(args.port, args.baud) 46 | 47 | print('Connecting to GSM modem on {0}...'.format(args.port)) 48 | try: 49 | modem.connect(args.pin) 50 | except PinRequiredError: 51 | sys.stderr.write('Error: SIM card PIN required. Please specify a PIN with the -p argument.\n') 52 | sys.exit(1) 53 | except IncorrectPinError: 54 | sys.stderr.write('Error: Incorrect SIM card PIN entered.\n') 55 | sys.exit(1) 56 | 57 | if args.debug: 58 | # Print debug info 59 | print('\n== MODEM DEBUG INFORMATION ==\n') 60 | print('ATI', modem.write('ATI', parseError=False)) 61 | print('AT+CGMI:', modem.write('AT+CGMI', parseError=False)) 62 | print('AT+CGMM:', modem.write('AT+CGMM', parseError=False)) 63 | print('AT+CGMR:', modem.write('AT+CGMR', parseError=False)) 64 | print('AT+CFUN=?:', modem.write('AT+CFUN=?', parseError=False)) 65 | print('AT+WIND=?:', modem.write('AT+WIND=?', parseError=False)) 66 | print('AT+WIND?:', modem.write('AT+WIND?', parseError=False)) 67 | print('AT+CPMS=?:', modem.write('AT+CPMS=?', parseError=False)) 68 | print('AT+CNMI=?:', modem.write('AT+CNMI=?', parseError=False)) 69 | print('AT+CVHU=?:', modem.write('AT+CVHU=?', parseError=False)) 70 | print('AT+CSMP?:', modem.write('AT+CSMP?', parseError=False)) 71 | print('AT+GCAP:', modem.write('AT+GCAP', parseError=False)) 72 | print('AT+CPIN?', modem.write('AT+CPIN?', parseError=False)) 73 | print('AT+CLAC:', modem.write('AT+CLAC', parseError=False)) 74 | print() 75 | else: 76 | # Print basic info 77 | print('\n== MODEM INFORMATION ==\n') 78 | print('Manufacturer:', modem.manufacturer) 79 | print('Model:', modem.model) 80 | print('Revision:', modem.revision if modem.revision != None else 'N/A') 81 | print('\nIMEI:', modem.imei if modem.imei != None else 'N/A') 82 | print('IMSI:', modem.imsi if modem.imsi != None else 'N/A') 83 | print('\nNetwork:', modem.networkName) 84 | print('Signal strength:', modem.signalStrength) 85 | print() 86 | 87 | if __name__ == '__main__': 88 | main() 89 | 90 | -------------------------------------------------------------------------------- /tools/sendsms.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | 4 | """\ 5 | Simple script to send an SMS message 6 | 7 | @author: Francois Aucamp 8 | """ 9 | from __future__ import print_function 10 | import sys, logging 11 | 12 | from gsmmodem.modem import GsmModem, SentSms 13 | from gsmmodem.exceptions import TimeoutException, PinRequiredError, IncorrectPinError 14 | 15 | def parseArgs(): 16 | """ Argument parser for Python 2.7 and above """ 17 | from argparse import ArgumentParser 18 | parser = ArgumentParser(description='Simple script for sending SMS messages') 19 | parser.add_argument('-i', '--port', metavar='PORT', help='port to which the GSM modem is connected; a number or a device name.') 20 | parser.add_argument('-b', '--baud', metavar='BAUDRATE', default=115200, help='set baud rate') 21 | parser.add_argument('-p', '--pin', metavar='PIN', default=None, help='SIM card PIN') 22 | parser.add_argument('-d', '--deliver', action='store_true', help='wait for SMS delivery report') 23 | parser.add_argument('destination', metavar='DESTINATION', help='destination mobile number') 24 | return parser.parse_args() 25 | 26 | def parseArgsPy26(): 27 | """ Argument parser for Python 2.6 """ 28 | from gsmtermlib.posoptparse import PosOptionParser, Option 29 | parser = PosOptionParser(description='Simple script for sending SMS messages') 30 | parser.add_option('-i', '--port', metavar='PORT', help='port to which the GSM modem is connected; a number or a device name.') 31 | parser.add_option('-b', '--baud', metavar='BAUDRATE', default=115200, help='set baud rate') 32 | parser.add_option('-p', '--pin', metavar='PIN', default=None, help='SIM card PIN') 33 | parser.add_option('-d', '--deliver', action='store_true', help='wait for SMS delivery report') 34 | parser.add_positional_argument(Option('--destination', metavar='DESTINATION', help='destination mobile number')) 35 | options, args = parser.parse_args() 36 | if len(args) != 1: 37 | parser.error('Incorrect number of arguments - please specify a DESTINATION to send to, e.g. {0} 012789456'.format(sys.argv[0])) 38 | else: 39 | options.destination = args[0] 40 | return options 41 | 42 | def main(): 43 | args = parseArgsPy26() if sys.version_info[0] == 2 and sys.version_info[1] < 7 else parseArgs() 44 | if args.port == None: 45 | sys.stderr.write('Error: No port specified. Please specify the port to which the GSM modem is connected using the -i argument.\n') 46 | sys.exit(1) 47 | modem = GsmModem(args.port, args.baud) 48 | # Uncomment the following line to see what the modem is doing: 49 | #logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.DEBUG) 50 | 51 | print('Connecting to GSM modem on {0}...'.format(args.port)) 52 | try: 53 | modem.connect(args.pin) 54 | except PinRequiredError: 55 | sys.stderr.write('Error: SIM card PIN required. Please specify a PIN with the -p argument.\n') 56 | sys.exit(1) 57 | except IncorrectPinError: 58 | sys.stderr.write('Error: Incorrect SIM card PIN entered.\n') 59 | sys.exit(1) 60 | print('Checking for network coverage...') 61 | try: 62 | modem.waitForNetworkCoverage(5) 63 | except TimeoutException: 64 | print('Network signal strength is not sufficient, please adjust modem position/antenna and try again.') 65 | modem.close() 66 | sys.exit(1) 67 | else: 68 | print('\nPlease type your message and press enter to send it:') 69 | text = raw_input('> ') 70 | if args.deliver: 71 | print ('\nSending SMS and waiting for delivery report...') 72 | else: 73 | print('\nSending SMS message...') 74 | try: 75 | sms = modem.sendSms(args.destination, text, waitForDeliveryReport=args.deliver) 76 | except TimeoutException: 77 | print('Failed to send message: the send operation timed out') 78 | modem.close() 79 | sys.exit(1) 80 | else: 81 | modem.close() 82 | if sms.report: 83 | print('Message sent{0}'.format(' and delivered OK.' if sms.status == SentSms.DELIVERED else ', but delivery failed.')) 84 | else: 85 | print('Message sent.') 86 | 87 | if __name__ == '__main__': 88 | main() 89 | 90 | --------------------------------------------------------------------------------