├── .bumpversion.cfg ├── .coveragerc ├── .editorconfig ├── .gitignore ├── .travis.yml ├── AUTHORS.rst ├── CHANGELOG.rst ├── CONTRIBUTING.rst ├── CONTRIBUTORS.rst ├── LICENSE ├── MAKEFILE ├── MANIFEST.in ├── README.rst ├── apex.conf.example ├── ci ├── bootstrap.py └── templates │ ├── .travis.yml │ └── tox.ini ├── docs ├── authors.rst ├── changelog.rst ├── conf.py ├── contributing.rst ├── index.rst ├── installation.rst ├── readme.rst ├── reference │ ├── apex.rst │ └── index.rst ├── releasing.rst ├── requirements.txt ├── spelling_wordlist.txt └── usage.rst ├── setup.cfg ├── setup.py ├── src └── apex │ ├── __init__.py │ ├── __main__.py │ ├── aprs │ ├── __init__.py │ ├── aprs_kiss.py │ ├── constants.py │ ├── decimal_degrees.py │ ├── igate.py │ └── util.py │ ├── buffers.py │ ├── cli.py │ ├── kiss │ ├── __init__.py │ ├── constants.py │ ├── kiss.py │ ├── kiss_serial.py │ └── kiss_tcp.py │ ├── plugin_loader.py │ ├── plugins │ ├── __init__.py │ ├── apexparadigm │ │ └── __init__.py │ ├── beacon │ │ └── __init__.py │ ├── id │ │ └── __init__.py │ └── status │ │ └── __init__.py │ ├── routing │ ├── __init__.py │ └── route.py │ └── util.py ├── tests ├── __init__.py ├── constants.py ├── context.py ├── kiss_mock.py ├── test_apex.py ├── test_aprs.py ├── test_kiss.py └── test_util.py └── tox.ini /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.0.6 3 | commit = False 4 | tag = False 5 | 6 | [bumpversion:file:setup.py] 7 | 8 | [bumpversion:file:docs/conf.py] 9 | 10 | [bumpversion:file:src/apex/__init__.py] 11 | 12 | [bumpversion:file:src/apex/aprs/__init__.py] 13 | 14 | [bumpversion:file:src/apex/kiss/__init__.py] 15 | 16 | [bumpversion:file:src/apex/routing/__init__.py] 17 | 18 | [bumpversion:file:src/apex/plugins/__init__.py] 19 | 20 | [bumpversion:file:src/apex/plugins/apexparadigm/__init__.py] 21 | 22 | [bumpversion:file:src/apex/plugins/beacon/__init__.py] 23 | 24 | [bumpversion:file:src/apex/plugins/id/__init__.py] 25 | 26 | [bumpversion:file:src/apex/plugins/status/__init__.py] 27 | 28 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [paths] 2 | source = src 3 | 4 | [run] 5 | branch = true 6 | source = 7 | src 8 | tests 9 | parallel = true 10 | 11 | [report] 12 | show_missing = true 13 | precision = 2 14 | omit = *migrations* 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 4 10 | charset = utf-8 11 | 12 | [*.{bat,cmd,ps1}] 13 | end_of_line = crlf 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | .eggs 13 | parts 14 | bin 15 | var 16 | sdist 17 | wheelhouse 18 | develop-eggs 19 | .installed.cfg 20 | lib 21 | lib64 22 | venv*/ 23 | pyvenv*/ 24 | 25 | # Installer logs 26 | pip-log.txt 27 | 28 | # Unit test / coverage reports 29 | .coverage 30 | .tox 31 | .coverage.* 32 | nosetests.xml 33 | coverage.xml 34 | htmlcov 35 | 36 | # Translations 37 | *.mo 38 | 39 | # Mr Developer 40 | .mr.developer.cfg 41 | .project 42 | .pydevproject 43 | .idea 44 | *.iml 45 | *.komodoproject 46 | 47 | # Complexity 48 | output/*.html 49 | output/*/index.html 50 | 51 | # Sphinx 52 | docs/_build 53 | 54 | .DS_Store 55 | *~ 56 | .*.sw[po] 57 | .build 58 | .ve 59 | .env 60 | .cache 61 | .pytest 62 | .bootstrap 63 | .appveyor.token 64 | *.bak 65 | **/__pycache__ 66 | 67 | apex.conf 68 | 69 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: '3.5' 3 | sudo: false 4 | env: 5 | global: 6 | - LD_PRELOAD=/lib/x86_64-linux-gnu/libSegFault.so 7 | - SEGFAULT_SIGNALS=all 8 | matrix: 9 | - TOXENV=check 10 | - TOXENV=docs 11 | 12 | - TOXENV=2.7-cover,coveralls,codecov 13 | - TOXENV=2.7-nocov 14 | - TOXENV=3.3-cover,coveralls,codecov 15 | - TOXENV=3.3-nocov 16 | - TOXENV=3.4-cover,coveralls,codecov 17 | - TOXENV=3.4-nocov 18 | - TOXENV=3.5-cover,coveralls,codecov 19 | - TOXENV=3.5-nocov 20 | - TOXENV=pypy-cover,coveralls,codecov 21 | - TOXENV=pypy-nocov 22 | before_install: 23 | - python --version 24 | - uname -a 25 | - lsb_release -a 26 | install: 27 | - pip install tox 28 | - virtualenv --version 29 | - easy_install --version 30 | - pip --version 31 | - tox --version 32 | script: 33 | - tox -v 34 | after_failure: 35 | - more .tox/log/* | cat 36 | - more .tox/*/log/* | cat 37 | before_cache: 38 | - rm -rf $HOME/.cache/pip/log 39 | cache: 40 | directories: 41 | - $HOME/.cache/pip 42 | notifications: 43 | email: 44 | on_success: never 45 | on_failure: always 46 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | 2 | Authors 3 | ======= 4 | 5 | * Jeffrey Phillips Freeman - http://JeffreyFreeman.me 6 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | 2 | Changelog 3 | ========= 4 | 5 | 0.0.6 6 | ----- 7 | 8 | 9 | 0.0.5 10 | ----- 11 | 12 | * Changed the APRS-IS config section to be named IGATE instead. 13 | * Output now displays IGATE as the source/destination instead of APRS-IS. 14 | * Made IGATE a reserved name in the configuration, it cannot be used for a TNC name. 15 | * Removed a catch-everything block, the result is exceptions will now cause the application to exit. 16 | * Fixed several bugs specific to python3, should now work under python3. 17 | * KISS TNC connections will now automatically reconnect if disconnected. 18 | 19 | 0.0.4 20 | ----- 21 | 22 | * Colorized the output from the plugins. 23 | * Removed packet_cache argument from plugins, it is no longer needed. 24 | * Mechanisms added to ensure plugins can not send the same packet twice, plugins no longer need to handle this explicitly. 25 | * Fixed a bug where packets can be digipeated multiple times. 26 | 27 | 0.0.3 28 | ----- 29 | 30 | * Reordered changelog version entries. 31 | * Fixed several mistakes in the README. 32 | 33 | 0.0.2 34 | ----- 35 | 36 | * The configfile command line argument added. 37 | * When no configfile argument present APEX will now search multiple default paths to find a configuration file. 38 | * Changed LICENSE file text to include the full text of the Apache Software License version 2. 39 | * Colorized some of the output. 40 | * Changed the way plugins are discovered, they can now be installed anywhere. 41 | * Fixed a bug in the APRS-IS class which threw a broken pipe error. 42 | * Refactored several classes and renamed them: Kiss class now has two subclasses and AprsInternetServer is renamed to IGate 43 | * Encapsulated IGate connection with a buffer that automatically reconnects when disconnected. 44 | * Removed a few obsolete and unused util functions. 45 | * Fix several errors thrown due to missing sections in the configuration file. 46 | 47 | 0.0.1 48 | ----- 49 | 50 | * First release on PyPI. 51 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | Contributions are welcome, and they are greatly appreciated! Every 6 | little bit helps, and credit will always be given. 7 | 8 | Bug reports 9 | =========== 10 | 11 | When `reporting a bug `_ please include: 12 | 13 | * Your operating system name and version. 14 | * Any details about your local setup that might be helpful in troubleshooting. 15 | * Detailed steps to reproduce the bug. 16 | 17 | Documentation improvements 18 | ========================== 19 | 20 | APEX could always use more documentation, whether as part of the 21 | official APEX docs, in docstrings, or even on the web in blog posts, 22 | articles, and such. 23 | 24 | Feature requests and feedback 25 | ============================= 26 | 27 | The best way to send feedback is to file an issue at https://github.com/Syncleus/apex/issues. 28 | 29 | If you are proposing a feature: 30 | 31 | * Explain in detail how it would work. 32 | * Keep the scope as narrow as possible, to make it easier to implement. 33 | * Remember that this is a volunteer-driven project, and that code contributions are welcome :) 34 | 35 | Development 36 | =========== 37 | 38 | To set up `apex` for local development: 39 | 40 | 1. Fork `apex `_ 41 | (look for the "Fork" button). 42 | 2. Clone your fork locally:: 43 | 44 | git clone git@github.com:your_name_here/apex.git 45 | 46 | 3. Create a branch for local development:: 47 | 48 | git checkout -b name-of-your-bugfix-or-feature 49 | 50 | Now you can make your changes locally. 51 | 52 | 4. When you're done making changes, run all the checks, doc builder and spell checker with `tox `_ one command:: 53 | 54 | tox 55 | 56 | 5. Commit your changes and push your branch to GitHub:: 57 | 58 | git add . 59 | git commit -m "Your detailed description of your changes." 60 | git push origin name-of-your-bugfix-or-feature 61 | 62 | 6. Submit a pull request through the GitHub website. 63 | 64 | Pull Request Guidelines 65 | ----------------------- 66 | 67 | If you need some code review or feedback while you're developing the code just make the pull request. 68 | 69 | For merging, you should: 70 | 71 | 1. Include passing tests (run ``tox``) [1]_. 72 | 2. Update documentation when there's new API, functionality etc. 73 | 3. Add a note to ``CHANGELOG.rst`` about the changes. 74 | 4. Add yourself to ``AUTHORS.rst``. 75 | 76 | .. [1] If you don't have all the necessary python versions available locally you can rely on Travis - it will 77 | `run the tests `_ for each change you add in the pull request. 78 | 79 | It will be slower though ... 80 | 81 | Tips 82 | ---- 83 | 84 | To run a subset of tests:: 85 | 86 | tox -e envname -- py.test -k test_myfeature 87 | 88 | To run all the test environments in *parallel* (you need to ``pip install detox``):: 89 | 90 | detox 91 | -------------------------------------------------------------------------------- /CONTRIBUTORS.rst: -------------------------------------------------------------------------------- 1 | * Jeffrey Phillips Freeman (WI2ARD) - http://JeffreyFreeman.me 2 | * Greg Albrecht W2GMD 3 | * Martin Murray (KD8LVZ) 4 | * Paul McMillan - https://github.com/PaulMcMillan 5 | * Russ Innes 6 | * John Hogenmiller (KB3DFZ) - https://github.com/ytjohn 7 | * Phil Gagnon N1HHG 8 | * Ben Benesh - https://github.com/bbene 9 | * Joe Goforth 10 | * Rick Eason 11 | * Jay Nugent 12 | * Pete Loveall (AE5PL) 13 | * darksidelemm - https://github.com/darksidelemm 14 | * agmuino - https://github.com/agmuino 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /MAKEFILE: -------------------------------------------------------------------------------- 1 | # Makefile for APEX Python Module. 2 | # 3 | # Source:: https://github.com/syncleus/apex 4 | # Author:: Jeffrey Phillips Freeman WI2ARD 5 | # Copyright:: Copyright 2016, Syncleus, Inc. 6 | # License:: Apache License, Version 2.0 7 | # 8 | 9 | 10 | .DEFAULT_GOAL := all 11 | 12 | 13 | all: clean develop 14 | 15 | develop: 16 | python setup.py develop 17 | 18 | install: 19 | python setup.py install 20 | 21 | uninstall: 22 | pip uninstall -y apex 23 | 24 | clean: 25 | @rm -rf *.egg* build dist *.py[oc] */*.py[co] cover doctest_pypi.cfg \ 26 | nosetests.xml pylint.log output.xml flake8.log tests.log \ 27 | test-result.xml htmlcov fab.log .coverage 28 | 29 | publish: 30 | python setup.py register sdist upload 31 | 32 | test: 33 | tox 34 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft docs 2 | graft src 3 | graft ci 4 | graft tests 5 | 6 | include .bumpversion.cfg 7 | include .coveragerc 8 | include .editorconfig 9 | 10 | include AUTHORS.rst 11 | include CHANGELOG.rst 12 | include CONTRIBUTORS.rst 13 | include CONTRIBUTING.rst 14 | include LICENSE 15 | include README.rst 16 | 17 | include apex.conf.example 18 | include MAKEFILE 19 | 20 | include tox.ini .travis.yml 21 | 22 | global-exclude *.py[cod] __pycache__ *.so *.dylib 23 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Overview 3 | ======== 4 | 5 | .. start-badges 6 | 7 | .. list-table:: 8 | :stub-columns: 1 9 | 10 | * - support 11 | - |docs| |gitter| 12 | * - tests 13 | - | |travis| |requires| 14 | | |coveralls| |codecov| 15 | | |landscape| |scrutinizer| |codacy| |codeclimate| 16 | * - package 17 | - |version| |downloads| |wheel| |supported-versions| |supported-implementations| 18 | 19 | .. |docs| image:: https://readthedocs.org/projects/apex/badge/?version=latest 20 | :target: http://apex.readthedocs.io/en/latest/ 21 | :alt: Documentation Status 22 | 23 | .. |travis| image:: https://travis-ci.org/Syncleus/apex.svg?branch=master 24 | :alt: Travis-CI Build Status 25 | :target: https://travis-ci.org/Syncleus/apex 26 | 27 | .. |requires| image:: https://requires.io/github/Syncleus/apex/requirements.svg?branch=master 28 | :alt: Requirements Status 29 | :target: https://requires.io/github/Syncleus/apex/requirements/?branch=master 30 | 31 | .. |coveralls| image:: https://coveralls.io/repos/github/Syncleus/apex/badge.svg?branch=master 32 | :alt: Coverage Status 33 | :target: https://coveralls.io/github/Syncleus/apex?branch=master 34 | 35 | .. |codecov| image:: https://codecov.io/github/Syncleus/apex/coverage.svg?branch=master 36 | :alt: Coverage Status 37 | :target: https://codecov.io/github/Syncleus/apex 38 | 39 | .. |landscape| image:: https://landscape.io/github/Syncleus/apex/master/landscape.svg?style=flat 40 | :target: https://landscape.io/github/Syncleus/apex/master 41 | :alt: Code Quality Status 42 | 43 | .. |codacy| image:: https://api.codacy.com/project/badge/Grade/4d662dc79744416b950273fb57a64d6e 44 | :target: https://www.codacy.com/app/freemo/apex?utm_source=github.com&utm_medium=referral&utm_content=Syncleus/apex&utm_campaign=Badge_Grade 45 | :alt: Codacy Code Quality Status 46 | 47 | .. |codeclimate| image:: https://codeclimate.com/github/Syncleus/apex/badges/gpa.svg 48 | :target: https://codeclimate.com/github/Syncleus/apex 49 | :alt: CodeClimate Quality Status 50 | 51 | .. |version| image:: https://img.shields.io/pypi/v/apex-radio.svg?style=flat 52 | :alt: PyPI Package latest release 53 | :target: https://pypi.python.org/pypi/apex-radio 54 | 55 | .. |downloads| image:: https://img.shields.io/pypi/dm/apex-radio.svg?style=flat 56 | :alt: PyPI Package monthly downloads 57 | :target: https://pypi.python.org/pypi/apex-radio 58 | 59 | .. |wheel| image:: https://img.shields.io/pypi/wheel/apex-radio.svg?style=flat 60 | :alt: PyPI Wheel 61 | :target: https://pypi.python.org/pypi/apex-radio 62 | 63 | .. |supported-versions| image:: https://img.shields.io/pypi/pyversions/apex-radio.svg?style=flat 64 | :alt: Supported versions 65 | :target: https://pypi.python.org/pypi/apex-radio 66 | 67 | .. |supported-implementations| image:: https://img.shields.io/pypi/implementation/apex-radio.svg?style=flat 68 | :alt: Supported implementations 69 | :target: https://pypi.python.org/pypi/apex-radio 70 | 71 | .. |scrutinizer| image:: https://img.shields.io/scrutinizer/g/Syncleus/apex/master.svg?style=flat 72 | :alt: Scrutinizer Status 73 | :target: https://scrutinizer-ci.com/g/Syncleus/apex/ 74 | 75 | .. |gitter| image:: https://badges.gitter.im/Syncleus/APEX.svg 76 | :alt: Join the chat at https://gitter.im/Syncleus/APEX 77 | :target: https://gitter.im/Syncleus/APEX?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge 78 | 79 | 80 | .. end-badges 81 | 82 | APEX is a next generation APRS based protocol. This repository represents the reference implementation and is a full features application for digipeating across multiple AX.25 KISS TNC devices using the full APEX stack. 83 | 84 | For more information on the project please check out `the project's home page `_. 85 | 86 | Installation 87 | ============ 88 | 89 | Install the application using pip. 90 | 91 | pip install apex-radio 92 | 93 | Running the app 94 | =============== 95 | 96 | The application is written for python 2 or 3. Once installed copy the apex.conf.example file over to apex.conf in the 97 | /etc directory, then edit the file and replace it with your details. Next just run the application with the following 98 | command. 99 | 100 | apex -v 101 | 102 | There isn't much to the application right now, so thats all you should need to run it. Digipeating will occur 103 | automatically and respond to the WIDEN-n paradigm as well as your own callsign. Cross-band repeating is enabled right 104 | now but only by specifying the call sign directly. The application is still pre-release so more features and 105 | configuration options should be added soon. 106 | 107 | This is Free software: Apache License v2 108 | 109 | Documentation 110 | ============= 111 | 112 | https://apex.readthedocs.io/ 113 | 114 | Development 115 | =========== 116 | 117 | Initial setup:: 118 | 119 | pip install -U pyenv tox 120 | pyenv install 2.7 3.3.6 3.4.5 3.5.2 pypy-5.4.1 121 | pyenv global 2.7 3.3.6 3.4.5 3.5.2 pypy-5.4.1 122 | 123 | NOTE: The specific versions mentioned above may be different for each platform. use `pyenv install --list` to view the 124 | list of available versions. You will need a version of 2.7.x, 3.3.x, 3.4.x, 3.5.x, and pypy. Try to use the latest 125 | available version for each. Also some flavors of pyenv have different formats for it's arguments. So read the pyenv 126 | documentation on your platform. 127 | 128 | To run all tests:: 129 | 130 | tox 131 | 132 | Note, to combine the coverage data from all the tox environments run: 133 | 134 | .. list-table:: 135 | :widths: 10 90 136 | :stub-columns: 1 137 | 138 | - - Windows 139 | - :: 140 | 141 | set PYTEST_ADDOPTS=--cov-append 142 | tox 143 | 144 | - - Other 145 | - :: 146 | 147 | PYTEST_ADDOPTS=--cov-append tox 148 | 149 | -------------------------------------------------------------------------------- /apex.conf.example: -------------------------------------------------------------------------------- 1 | [TNC KENWOOD] 2 | com_port=/dev/ttyUSB1 3 | baud=9600 4 | parity=none 5 | stop_bits=1 6 | byte_size=8 7 | port_count=1 8 | kiss_init=MODE_INIT_KENWOOD_D710 9 | 10 | [TNC RPR] 11 | com_port=/dev/ttyUSB0 12 | baud=38400 13 | parity=none 14 | stop_bits=1 15 | byte_Size=8 16 | port_count=1 17 | kiss_init=MODE_INIT_W8DED 18 | 19 | [PORT KENWOOD-1] 20 | identifier=WI2ARD-1 21 | net=2M1 22 | tnc_port=0 23 | beacon_path=WIDE1-1,WIDE2-2 24 | status_path=WIDE1-1,WIDE2-2 25 | beacon_text=!/:=i@;N.G& --PHG5790/G/D R-I-R H24 C30 26 | status_text=>Listening on 146.52Mhz http://JeffreyFreeman.me 27 | id_text=WI2ARD/30M1 GATE/2M1 WI2ARD-1/2M1 WIDEN-n IGATE 28 | id_path=WIDE1-1,WIDE2-2 29 | 30 | [PORT RPR-1] 31 | identifier=WI2ARD 32 | net=30M1 33 | tnc_port=0 34 | beacon_path=WIDE1-1 35 | status_path=WIDE1-1 36 | beacon_text=!/:=i@;N.G& --PHG5210/G/D R-I-R H24 C1 37 | status_text=>Robust Packet Radio http://JeffreyFreeman.me 38 | id_text=WI2ARD/30M1 GATE/2M1 WI2ARD-1/2M1 WIDEN-n IGATE 39 | id_path=WIDE1-1 40 | 41 | [IGATE] 42 | callsign=WI2ARD 43 | password=12345 44 | server=noam.aprs2.net 45 | server_port=14580 46 | 47 | -------------------------------------------------------------------------------- /ci/bootstrap.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from __future__ import absolute_import, print_function, unicode_literals 4 | 5 | import os 6 | import sys 7 | from os.path import abspath 8 | from os.path import dirname 9 | from os.path import exists 10 | from os.path import join 11 | 12 | 13 | if __name__ == "__main__": 14 | base_path = dirname(dirname(abspath(__file__))) 15 | print("Project path: {0}".format(base_path)) 16 | env_path = join(base_path, ".tox", "bootstrap") 17 | if sys.platform == "win32": 18 | bin_path = join(env_path, "Scripts") 19 | else: 20 | bin_path = join(env_path, "bin") 21 | if not exists(env_path): 22 | import subprocess 23 | 24 | print("Making bootstrap env in: {0} ...".format(env_path)) 25 | try: 26 | subprocess.check_call(["virtualenv", env_path]) 27 | except subprocess.CalledProcessError: 28 | subprocess.check_call([sys.executable, "-m", "virtualenv", env_path]) 29 | print("Installing `jinja2` and `matrix` into bootstrap environment...") 30 | subprocess.check_call([join(bin_path, "pip"), "install", "jinja2", "matrix"]) 31 | activate = join(bin_path, "activate_this.py") 32 | # noinspection PyCompatibility 33 | exec(compile(open(activate, "rb").read(), activate, "exec"), dict(__file__=activate)) 34 | 35 | import jinja2 36 | 37 | import matrix 38 | 39 | jinja = jinja2.Environment( 40 | loader=jinja2.FileSystemLoader(join(base_path, "ci", "templates")), 41 | trim_blocks=True, 42 | lstrip_blocks=True, 43 | keep_trailing_newline=True 44 | ) 45 | 46 | tox_environments = {} 47 | for (alias, conf) in matrix.from_file(join(base_path, "setup.cfg")).items(): 48 | python = conf["python_versions"] 49 | deps = conf["dependencies"] 50 | tox_environments[alias] = { 51 | "python": "python" + python if "py" not in python else python, 52 | "deps": deps.split(), 53 | } 54 | if "coverage_flags" in conf: 55 | cover = {"false": False, "true": True}[conf["coverage_flags"].lower()] 56 | tox_environments[alias].update(cover=cover) 57 | if "environment_variables" in conf: 58 | env_vars = conf["environment_variables"] 59 | tox_environments[alias].update(env_vars=env_vars.split()) 60 | 61 | for name in os.listdir(join("ci", "templates")): 62 | with open(join(base_path, name), "w") as fh: 63 | fh.write(jinja.get_template(name).render(tox_environments=tox_environments)) 64 | print("Wrote {}".format(name)) 65 | print("DONE.") 66 | -------------------------------------------------------------------------------- /ci/templates/.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: '3.5' 3 | sudo: false 4 | env: 5 | global: 6 | - LD_PRELOAD=/lib/x86_64-linux-gnu/libSegFault.so 7 | - SEGFAULT_SIGNALS=all 8 | matrix: 9 | - TOXENV=check 10 | - TOXENV=docs 11 | {% for env, config in tox_environments|dictsort %}{{ '' }} 12 | - TOXENV={{ env }}{% if config.cover %},coveralls,codecov{% endif -%} 13 | {% endfor %} 14 | 15 | before_install: 16 | - python --version 17 | - uname -a 18 | - lsb_release -a 19 | install: 20 | - pip install tox 21 | - virtualenv --version 22 | - easy_install --version 23 | - pip --version 24 | - tox --version 25 | script: 26 | - tox -v 27 | after_failure: 28 | - more .tox/log/* | cat 29 | - more .tox/*/log/* | cat 30 | before_cache: 31 | - rm -rf $HOME/.cache/pip/log 32 | cache: 33 | directories: 34 | - $HOME/.cache/pip 35 | notifications: 36 | email: 37 | on_success: never 38 | on_failure: always 39 | -------------------------------------------------------------------------------- /ci/templates/tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | clean, 4 | check, 5 | {% for env in tox_environments|sort %} 6 | {{ env }}, 7 | {% endfor %} 8 | report, 9 | docs 10 | 11 | [testenv] 12 | basepython = 13 | {docs,spell}: python2.7 14 | {clean,check,report,extension-coveralls,coveralls,codecov}: python3.5 15 | setenv = 16 | PYTHONPATH={toxinidir}/tests 17 | PYTHONUNBUFFERED=yes 18 | passenv = 19 | * 20 | deps = 21 | pytest 22 | pytest-travis-fold 23 | commands = 24 | {posargs:py.test -vv --ignore=src} 25 | 26 | [testenv:spell] 27 | setenv = 28 | SPELLCHECK=1 29 | commands = 30 | sphinx-build -b spelling docs dist/docs 31 | skip_install = true 32 | usedevelop = false 33 | deps = 34 | -r{toxinidir}/docs/requirements.txt 35 | sphinxcontrib-spelling 36 | pyenchant 37 | 38 | [testenv:docs] 39 | deps = 40 | -r{toxinidir}/docs/requirements.txt 41 | commands = 42 | sphinx-build {posargs:-E} -b html docs dist/docs 43 | sphinx-build -b linkcheck docs dist/docs 44 | 45 | [testenv:bootstrap] 46 | deps = 47 | jinja2 48 | matrix 49 | skip_install = true 50 | usedevelop = false 51 | commands = 52 | python ci/bootstrap.py 53 | passenv = 54 | * 55 | 56 | [testenv:check] 57 | deps = 58 | docutils 59 | check-manifest 60 | flake8 61 | readme-renderer 62 | pygments 63 | isort 64 | skip_install = true 65 | usedevelop = false 66 | commands = 67 | python setup.py check --strict --metadata --restructuredtext 68 | check-manifest {toxinidir} 69 | flake8 src tests setup.py 70 | isort --verbose --check-only --diff --recursive src tests setup.py 71 | 72 | [testenv:coveralls] 73 | deps = 74 | coveralls 75 | skip_install = true 76 | usedevelop = false 77 | commands = 78 | coverage combine --append 79 | coverage report 80 | coveralls [] 81 | 82 | [testenv:codecov] 83 | deps = 84 | codecov 85 | skip_install = true 86 | usedevelop = false 87 | commands = 88 | coverage combine --append 89 | coverage report 90 | coverage xml --ignore-errors 91 | codecov [] 92 | 93 | 94 | [testenv:report] 95 | deps = coverage 96 | skip_install = true 97 | usedevelop = false 98 | commands = 99 | coverage combine --append 100 | coverage report 101 | coverage html 102 | 103 | [testenv:clean] 104 | commands = coverage erase 105 | skip_install = true 106 | usedevelop = false 107 | deps = coverage 108 | 109 | {% for env, config in tox_environments|dictsort %} 110 | [testenv:{{ env }}] 111 | basepython = {env:TOXPYTHON:{{ config.python }}} 112 | {% if config.cover or config.env_vars %} 113 | setenv = 114 | {[testenv]setenv} 115 | {% endif %} 116 | {% for var in config.env_vars %} 117 | {{ var }} 118 | {% endfor %} 119 | {% if config.cover %} 120 | WITH_COVERAGE=yes 121 | usedevelop = true 122 | commands = 123 | {posargs:py.test --cov --cov-report=term-missing -vv} 124 | {% endif %} 125 | {% if config.cover or config.deps %} 126 | deps = 127 | {[testenv]deps} 128 | {% endif %} 129 | {% if config.cover %} 130 | pytest-cov 131 | {% endif %} 132 | {% for dep in config.deps %} 133 | {{ dep }} 134 | {% endfor %} 135 | 136 | {% endfor %} 137 | 138 | 139 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | 5 | 6 | extensions = [ 7 | 'sphinx.ext.autodoc', 8 | 'sphinx.ext.autosummary', 9 | 'sphinx.ext.coverage', 10 | 'sphinx.ext.doctest', 11 | 'sphinx.ext.extlinks', 12 | 'sphinx.ext.ifconfig', 13 | 'sphinx.ext.napoleon', 14 | 'sphinx.ext.todo', 15 | 'sphinx.ext.viewcode', 16 | ] 17 | if os.getenv('SPELLCHECK'): 18 | extensions += 'sphinxcontrib.spelling', 19 | spelling_show_suggestions = True 20 | spelling_lang = 'en_US' 21 | 22 | source_suffix = '.rst' 23 | master_doc = 'index' 24 | project = u'APEX' 25 | year = '2016' 26 | author = u'Jeffrey Phillips Freeman (WI2ARD)' 27 | copyright = '{0}, {1}'.format(year, author) 28 | version = release = u'0.0.6' 29 | 30 | pygments_style = 'trac' 31 | templates_path = ['.'] 32 | extlinks = { 33 | 'issue': ('https://github.com/Syncleus/apex/issues/%s', '#'), 34 | 'pr': ('https://github.com/Syncleus/apex/pull/%s', 'PR #'), 35 | } 36 | import sphinx_py3doc_enhanced_theme 37 | html_theme = "sphinx_py3doc_enhanced_theme" 38 | html_theme_path = [sphinx_py3doc_enhanced_theme.get_html_theme_path()] 39 | html_theme_options = { 40 | 'githuburl': 'https://github.com/Syncleus/apex/' 41 | } 42 | 43 | html_use_smartypants = True 44 | html_last_updated_fmt = '%b %d, %Y' 45 | html_split_index = False 46 | html_sidebars = { 47 | '**': ['searchbox.html', 'globaltoc.html', 'sourcelink.html'], 48 | } 49 | html_short_title = '%s-%s' % (project, version) 50 | 51 | napoleon_use_ivar = True 52 | napoleon_use_rtype = False 53 | napoleon_use_param = False 54 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Contents 3 | ======== 4 | 5 | .. toctree:: 6 | :maxdepth: 2 7 | 8 | readme 9 | installation 10 | usage 11 | reference/index 12 | contributing 13 | releasing 14 | authors 15 | changelog 16 | 17 | Indices and tables 18 | ================== 19 | 20 | * :ref:`genindex` 21 | * :ref:`modindex` 22 | * :ref:`search` 23 | 24 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Installation 3 | ============ 4 | 5 | At the command line:: 6 | 7 | pip install apex-radio 8 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /docs/reference/apex.rst: -------------------------------------------------------------------------------- 1 | apex 2 | ==== 3 | 4 | .. testsetup:: 5 | 6 | from apex import * 7 | 8 | .. automodule:: apex 9 | 10 | .. automodule:: apex.aprs 11 | 12 | .. automodule:: apex.kiss 13 | -------------------------------------------------------------------------------- /docs/reference/index.rst: -------------------------------------------------------------------------------- 1 | Reference 2 | ========= 3 | 4 | .. toctree:: 5 | :glob: 6 | 7 | apex* 8 | -------------------------------------------------------------------------------- /docs/releasing.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Releasing 3 | ========= 4 | 5 | * Ensure you have an account on PyPI, if you do not create one `here `_. 6 | 7 | * Create or verify your `~/.pypirc` file. It should look like this:: 8 | 9 | [distutils] 10 | index-servers = 11 | pypi 12 | pypitest 13 | 14 | [pypi] 15 | repository=https://pypi.python.org/pypi 16 | username = 17 | password = 18 | 19 | [pypitest] 20 | repository=https://testpypi.python.org/pypi 21 | username = 22 | password = 23 | 24 | 25 | * Update CHANGELOG.rst 26 | 27 | * Commit the changes:: 28 | 29 | git add CHANGELOG.rst 30 | git commit -m "Changelog for upcoming release 0.1.1." 31 | 32 | 33 | * Install the package again for local development, but with the new version number:: 34 | 35 | python setup.py develop 36 | 37 | 38 | * Run the tests:: 39 | 40 | tox 41 | 42 | 43 | * Release on PyPI by uploading both sdist and wheel:: 44 | 45 | python setup.py sdist upload -r pypi 46 | python setup.py sdist upload -r pypitest 47 | python setup.py bdist_wheel --universal upload -r pypi 48 | python setup.py bdist_wheel --universal upload -r pypitest 49 | 50 | NOTE: Make sure you have Python Wheel installed for your distribution or else the above commands will not work. 51 | 52 | * Create git tag for released version:: 53 | 54 | git tag -a v0.1.1 -m "version 0.1.1" 55 | 56 | 57 | * Update version number (can also be minor or major):: 58 | 59 | bumpversion patch 60 | 61 | 62 | * Commit the version bump changes:: 63 | 64 | git add . 65 | git commit -m "Bumping version for release cycle" 66 | 67 | 68 | * Test that it pip installs:: 69 | 70 | pip install apex-radio 71 | 72 | 73 | 74 | * Push: `git push` 75 | 76 | * Push tags: `git push --tags` 77 | 78 | * Check the PyPI listing page to make sure that the README, release notes, and roadmap display properly. If not, copy 79 | and paste the RestructuredText into `ninjs `_ to find out what broke the formatting. 80 | 81 | * Edit the release on `GitHub `_ . Paste the release notes into the 82 | release's release page, and come up with a title for the release. 83 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx>=1.3 2 | sphinx-py3doc-enhanced-theme 3 | -e . 4 | -------------------------------------------------------------------------------- /docs/spelling_wordlist.txt: -------------------------------------------------------------------------------- 1 | builtin 2 | builtins 3 | classmethod 4 | staticmethod 5 | classmethods 6 | staticmethods 7 | args 8 | kwargs 9 | callstack 10 | Changelog 11 | Indices 12 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | APEX is a standalone application. To use it simply call the `apex` command from the command line. for a list of valid 6 | commands use `apex --help`. 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.rst 3 | 4 | [bdist_wheel] 5 | universal = 1 6 | 7 | [flake8] 8 | max-line-length = 140 9 | exclude = tests/*,*/migrations/*,*/south_migrations/* 10 | 11 | [pytest] 12 | norecursedirs = 13 | .git 14 | .tox 15 | .env 16 | dist 17 | build 18 | south_migrations 19 | migrations 20 | python_files = 21 | test_*.py 22 | *_test.py 23 | tests.py 24 | addopts = 25 | -rxEfsw 26 | --strict 27 | --ignore=docs/conf.py 28 | --ignore=setup.py 29 | --ignore=ci 30 | --ignore=.eggs 31 | --doctest-modules 32 | --doctest-glob=\*.rst 33 | --tb=short 34 | 35 | [isort] 36 | force_single_line=True 37 | line_length=120 38 | known_first_party=apex 39 | default_section=THIRDPARTY 40 | forced_separate=test_apex 41 | not_skip = __init__.py 42 | skip = migrations, south_migrations 43 | 44 | [matrix] 45 | # This is the configuration for the `./bootstrap.py` script. 46 | # It generates `.travis.yml`, `tox.ini` and `appveyor.yml`. 47 | # 48 | # Syntax: [alias:] value [!variable[glob]] [&variable[glob]] 49 | # 50 | # alias: 51 | # - is used to generate the tox environment 52 | # - it's optional 53 | # - if not present the alias will be computed from the `value` 54 | # value: 55 | # - a value of "-" means empty 56 | # !variable[glob]: 57 | # - exclude the combination of the current `value` with 58 | # any value matching the `glob` in `variable` 59 | # - can use as many you want 60 | # &variable[glob]: 61 | # - only include the combination of the current `value` 62 | # when there's a value matching `glob` in `variable` 63 | # - can use as many you want 64 | 65 | python_versions = 66 | 2.7 67 | 3.3 68 | 3.4 69 | 3.5 70 | pypy 71 | 72 | dependencies = 73 | # 1.4: Django==1.4.16 !python_versions[3.*] 74 | # 1.5: Django==1.5.11 75 | # 1.6: Django==1.6.8 76 | # 1.7: Django==1.7.1 !python_versions[2.6] 77 | # Deps commented above are provided as examples. That's what you would use in a Django project. 78 | 79 | coverage_flags = 80 | cover: true 81 | nocov: false 82 | 83 | environment_variables = 84 | - 85 | 86 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 -*- 3 | from __future__ import absolute_import 4 | from __future__ import print_function 5 | import io 6 | import re 7 | from glob import glob 8 | from os.path import basename 9 | from os.path import dirname 10 | from os.path import join 11 | from os.path import splitext 12 | from setuptools import find_packages 13 | from setuptools import setup 14 | 15 | __author__ = 'Jeffrey Phillips Freeman (WI2ARD)' 16 | __maintainer__ = 'Jeffrey Phillips Freeman (WI2ARD)' 17 | __email__ = 'jeffrey.freeman@syncleus.com' 18 | __license__ = 'Apache License, Version 2.0' 19 | __copyright__ = 'Copyright 2016, Syncleus, Inc. and contributors' 20 | __credits__ = [] 21 | 22 | 23 | def read(*names, **kwargs): 24 | return io.open( 25 | join(dirname(__file__), *names), 26 | encoding=kwargs.get('encoding', 'utf8') 27 | ).read() 28 | 29 | 30 | setup( 31 | name='apex-radio', 32 | version='0.0.6', 33 | license='Apache Software License', 34 | description='APEX reference implementation.', 35 | long_description='%s\n%s' % ( 36 | re.compile('^.. start-badges.*^.. end-badges', re.M | re.S).sub('', read('README.rst')), 37 | re.sub(':[a-z]+:`~?(.*?)`', r'``\1``', read('CHANGELOG.rst')) 38 | ), 39 | author='Jeffrey Phillips Freeman (WI2ARD)', 40 | author_email='jeffrey.freeman@syncleus.com', 41 | url='http://ApexProtocol.com', 42 | download_url='https://github.com/Syncleus/apex/archive/v0.0.6.tar.gz', 43 | packages=find_packages('src'), 44 | package_dir={'': 'src'}, 45 | py_modules=[splitext(basename(path))[0] for path in glob('src/*.py')], 46 | include_package_data=True, 47 | zip_safe=False, 48 | classifiers=[ 49 | # complete classifier list: http://pypi.python.org/pypi?%3Aaction=list_classifiers 50 | 'Development Status :: 2 - Pre-Alpha', 51 | 'Intended Audience :: End Users/Desktop', 52 | 'License :: OSI Approved :: Apache Software License', 53 | 'Operating System :: Unix', 54 | 'Operating System :: POSIX', 55 | 'Operating System :: Microsoft :: Windows', 56 | 'Programming Language :: Python', 57 | 'Programming Language :: Python :: 2.7', 58 | 'Programming Language :: Python :: 3', 59 | 'Programming Language :: Python :: 3.3', 60 | 'Programming Language :: Python :: 3.4', 61 | 'Programming Language :: Python :: 3.5', 62 | 'Programming Language :: Python :: Implementation :: PyPy', 63 | 'Environment :: Console', 64 | 'Environment :: No Input/Output (Daemon)', 65 | 'Natural Language :: English', 66 | # uncomment if you test on these interpreters: 67 | # 'Programming Language :: Python :: Implementation :: IronPython', 68 | # 'Programming Language :: Python :: Implementation :: Jython', 69 | # 'Programming Language :: Python :: Implementation :: Stackless', 70 | 'Topic :: Communications :: Ham Radio', 71 | ], 72 | keywords=[ 73 | 'Ham Radio', 'APEX', 'APRS' 74 | ], 75 | install_requires=[ 76 | 'click >= 6.6', 77 | 'six >= 1.10.0', 78 | 'pynmea2 >= 1.4.2', 79 | 'pyserial >= 2.7', 80 | 'requests >= 2.7.0', 81 | 'cachetools >= 1.1.5' 82 | ], 83 | extras_require={ 84 | # eg: 85 | # 'rst': ['docutils>=0.11'], 86 | # ':python_version=="2.6"': ['argparse'], 87 | }, 88 | entry_points={ 89 | 'console_scripts': [ 90 | 'apex = apex.cli:main', 91 | ] 92 | }, 93 | ) 94 | -------------------------------------------------------------------------------- /src/apex/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Main module for APEX refernce implementation application. 3 | """ 4 | 5 | # These imports are for python3 compatibility inside python2 6 | from __future__ import absolute_import 7 | from __future__ import division 8 | from __future__ import print_function 9 | 10 | from .buffers import NonrepeatingBuffer # noqa: F401 11 | from .buffers import ReconnectingPacketBuffer # noqa: F401 12 | from .util import echo_colorized_error # noqa: F401 13 | from .util import echo_colorized_frame # noqa: F401 14 | from .util import echo_colorized_warning # noqa: F401 15 | 16 | __author__ = 'Jeffrey Phillips Freeman (WI2ARD)' 17 | __maintainer__ = 'Jeffrey Phillips Freeman (WI2ARD)' 18 | __email__ = 'jeffrey.freeman@syncleus.com' 19 | __license__ = 'Apache License, Version 2.0' 20 | __copyright__ = 'Copyright 2016, Syncleus, Inc. and contributors' 21 | __credits__ = [] 22 | __version__ = '0.0.6' 23 | -------------------------------------------------------------------------------- /src/apex/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Entrypoint module, in case you use `python -mapex`. 6 | 7 | 8 | Why does this file exist, and why __main__? For more info, read: 9 | 10 | - https://www.python.org/dev/peps/pep-0338/ 11 | - https://docs.python.org/2/using/cmdline.html#cmdoption-m 12 | - https://docs.python.org/3/using/cmdline.html#cmdoption-m 13 | """ 14 | 15 | # These imports are for python3 compatibility inside python2 16 | from __future__ import absolute_import 17 | from __future__ import division 18 | from __future__ import print_function 19 | 20 | from apex.cli import main 21 | 22 | __author__ = 'Jeffrey Phillips Freeman (WI2ARD)' 23 | __maintainer__ = 'Jeffrey Phillips Freeman (WI2ARD)' 24 | __email__ = 'jeffrey.freeman@syncleus.com' 25 | __license__ = 'Apache License, Version 2.0' 26 | __copyright__ = 'Copyright 2016, Syncleus, Inc. and contributors' 27 | __credits__ = [] 28 | 29 | if __name__ == '__main__': 30 | main(None) 31 | -------------------------------------------------------------------------------- /src/apex/aprs/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # APRS Python Module. 5 | 6 | """ 7 | APRS Python Module. 8 | ~~~~ 9 | 10 | 11 | :author: Jeffrey Phillips Freeman WI2ARD 12 | :copyright: Copyright 2016 Syncleus, Inc. and contributors 13 | :license: Apache License, Version 2.0 14 | :source: 15 | 16 | """ 17 | 18 | # These imports are for python3 compatibility inside python2 19 | from __future__ import absolute_import 20 | from __future__ import division 21 | from __future__ import print_function 22 | 23 | import logging 24 | 25 | from .aprs_kiss import AprsKiss # noqa: F401 26 | from .igate import IGate # noqa: F401 27 | 28 | __author__ = 'Jeffrey Phillips Freeman (WI2ARD)' 29 | __maintainer__ = 'Jeffrey Phillips Freeman (WI2ARD)' 30 | __email__ = 'jeffrey.freeman@syncleus.com' 31 | __license__ = 'Apache License, Version 2.0' 32 | __copyright__ = 'Copyright 2016, Syncleus, Inc. and contributors' 33 | __credits__ = [] 34 | __version__ = '0.0.6' 35 | 36 | # Set default logging handler to avoid "No handler found" warnings. 37 | try: # Python 2.7+ 38 | from logging import NullHandler 39 | except ImportError: 40 | class NullHandler(logging.Handler): 41 | """Default logging handler to avoid "No handler found" warnings.""" 42 | def emit(self, record): 43 | """Default logging handler to avoid "No handler found" warnings.""" 44 | pass 45 | 46 | logging.getLogger(__name__).addHandler(NullHandler()) 47 | -------------------------------------------------------------------------------- /src/apex/aprs/aprs_kiss.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """APRS KISS Class Definitions""" 5 | 6 | # These imports are for python3 compatibility inside python2 7 | from __future__ import absolute_import 8 | from __future__ import division 9 | from __future__ import print_function 10 | 11 | import logging 12 | import threading 13 | 14 | import apex.kiss 15 | 16 | __author__ = 'Jeffrey Phillips Freeman (WI2ARD)' 17 | __maintainer__ = 'Jeffrey Phillips Freeman (WI2ARD)' 18 | __email__ = 'jeffrey.freeman@syncleus.com' 19 | __license__ = 'Apache License, Version 2.0' 20 | __copyright__ = 'Copyright 2016, Syncleus, Inc. and contributors' 21 | __credits__ = [] 22 | 23 | 24 | class AprsKiss(object): 25 | 26 | """APRS interface.""" 27 | 28 | def __init__(self, data_stream): 29 | self.data_stream = data_stream 30 | self.lock = threading.Lock() 31 | 32 | @staticmethod 33 | def __decode_frame(raw_frame): 34 | """ 35 | Decodes a KISS-encoded APRS frame. 36 | 37 | :param raw_frame: KISS-encoded frame to decode. 38 | :type raw_frame: str 39 | 40 | :return: APRS frame-as-dict. 41 | :rtype: dict 42 | """ 43 | logging.debug('raw_frame=%s', raw_frame) 44 | frame = {} 45 | frame_len = len(raw_frame) 46 | 47 | if frame_len > 16: 48 | for raw_slice in range(0, frame_len - 2): 49 | # Is address field length correct? 50 | if raw_frame[raw_slice] & 0x01 and ((raw_slice + 1) % 7) == 0: 51 | i = (raw_slice + 1) / 7 52 | # Less than 2 callsigns? 53 | if 1 < i < 11: 54 | if raw_frame[raw_slice + 1] & 0x03 is 0x03 and raw_frame[raw_slice + 2] in [0xf0, 0xcf]: 55 | frame['text'] = ''.join(map(chr, raw_frame[raw_slice + 3:])) 56 | frame['destination'] = AprsKiss.__identity_as_string(AprsKiss.__extract_callsign(raw_frame)) 57 | frame['source'] = AprsKiss.__identity_as_string(AprsKiss.__extract_callsign(raw_frame[7:])) 58 | frame['path'] = AprsKiss.__extract_path(int(i), raw_frame) 59 | return frame 60 | 61 | logging.debug('frame=%s', frame) 62 | return frame 63 | 64 | @staticmethod 65 | def __valid_frame(raw_frame): 66 | logging.debug('raw_frame=%s', raw_frame) 67 | frame_len = len(raw_frame) 68 | 69 | if frame_len > 16: 70 | for raw_slice in range(0, frame_len - 2): 71 | # Is address field length correct? 72 | if raw_frame[raw_slice] & 0x01 and ((raw_slice + 1) % 7) == 0: 73 | i = (raw_slice + 1) / 7 74 | # Less than 2 callsigns? 75 | if 1 < i < 11: 76 | if raw_frame[raw_slice + 1] & 0x03 is 0x03 and raw_frame[raw_slice + 2] in [0xf0, 0xcf]: 77 | return True 78 | return False 79 | 80 | @staticmethod 81 | def __extract_path(start, raw_frame): 82 | """Extracts path from raw APRS KISS frame. 83 | 84 | :param start: 85 | :param raw_frame: Raw APRS frame from a KISS device. 86 | 87 | :return: Full path from APRS frame. 88 | :rtype: list 89 | """ 90 | full_path = [] 91 | 92 | for i in range(2, start): 93 | path = AprsKiss.__identity_as_string(AprsKiss.__extract_callsign(raw_frame[i * 7:])) 94 | if path: 95 | if raw_frame[i * 7 + 6] & 0x80: 96 | full_path.append(''.join([path, '*'])) 97 | else: 98 | full_path.append(path) 99 | 100 | return full_path 101 | 102 | @staticmethod 103 | def __extract_callsign(raw_frame): 104 | """ 105 | Extracts callsign from a raw KISS frame. 106 | 107 | :param raw_frame: Raw KISS Frame to decode. 108 | :returns: Dict of callsign and ssid. 109 | :rtype: dict 110 | """ 111 | callsign = ''.join([chr(x >> 1) for x in raw_frame[:6]]).strip() 112 | ssid = ((raw_frame[6]) >> 1) & 0x0f 113 | return {'callsign': callsign, 'ssid': ssid} 114 | 115 | @staticmethod 116 | def __identity_as_string(identity): 117 | """ 118 | Returns a fully-formatted callsign (Callsign + SSID). 119 | 120 | :param identity: Callsign Dictionary {'callsign': '', 'ssid': n} 121 | :type callsign: dict 122 | :returns: Callsign[-SSID]. 123 | :rtype: str 124 | """ 125 | if identity['ssid'] > 0: 126 | return '-'.join([identity['callsign'], str(identity['ssid'])]) 127 | else: 128 | return identity['callsign'] 129 | 130 | @staticmethod 131 | def __encode_frame(frame): 132 | """ 133 | Encodes an APRS frame-as-dict as a KISS frame. 134 | 135 | :param frame: APRS frame-as-dict to encode. 136 | :type frame: dict 137 | 138 | :return: KISS-encoded APRS frame. 139 | :rtype: list 140 | """ 141 | enc_frame = AprsKiss.__encode_callsign(AprsKiss.__parse_identity_string(frame['destination'])) + \ 142 | AprsKiss.__encode_callsign(AprsKiss.__parse_identity_string(frame['source'])) 143 | for p in frame['path']: 144 | enc_frame += AprsKiss.__encode_callsign(AprsKiss.__parse_identity_string(p)) 145 | 146 | return enc_frame[:-1] + [enc_frame[-1] | 0x01] + [apex.kiss.constants.SLOT_TIME] + [0xf0]\ 147 | + [ord(c) for c in frame['text']] 148 | 149 | @staticmethod 150 | def __encode_callsign(callsign): 151 | """ 152 | Encodes a callsign-dict within a KISS frame. 153 | 154 | :param callsign: Callsign-dict to encode. 155 | :type callsign: dict 156 | 157 | :return: KISS-encoded callsign. 158 | :rtype: list 159 | """ 160 | call_sign = callsign['callsign'] 161 | 162 | enc_ssid = (callsign['ssid'] << 1) | 0x60 163 | 164 | if '*' in call_sign: 165 | call_sign = call_sign.replace('*', '') 166 | enc_ssid |= 0x80 167 | 168 | while len(call_sign) < 6: 169 | call_sign = ''.join([call_sign, ' ']) 170 | 171 | encoded = [] 172 | for p in call_sign: 173 | encoded += [ord(p) << 1] 174 | return encoded + [enc_ssid] 175 | 176 | @staticmethod 177 | def __parse_identity_string(identity_string): 178 | """ 179 | Creates callsign-as-dict from callsign-as-string. 180 | 181 | :param identity_string: Callsign-as-string (with or without ssid). 182 | :type raw_callsign: str 183 | 184 | :return: Callsign-as-dict. 185 | :rtype: dict 186 | """ 187 | # If we are parsing a spent token then first lets get rid of the astresick suffix. 188 | if identity_string.endswith('*'): 189 | identity_string = identity_string[:-1] 190 | 191 | if '-' in identity_string: 192 | call_sign, ssid = identity_string.split('-') 193 | else: 194 | call_sign = identity_string 195 | ssid = 0 196 | return {'callsign': call_sign, 'ssid': int(ssid)} 197 | 198 | def connect(self, *args, **kwargs): 199 | self.data_stream.connect(*args, **kwargs) 200 | 201 | def close(self, *args, **kwargs): 202 | self.data_stream.close(*args, **kwargs) 203 | 204 | def write(self, frame, *args, **kwargs): 205 | """Writes APRS-encoded frame to KISS device. 206 | 207 | :param frame: APRS frame to write to KISS device. 208 | :type frame: dict 209 | """ 210 | with self.lock: 211 | encoded_frame = AprsKiss.__encode_frame(frame) 212 | if AprsKiss.__valid_frame(encoded_frame): 213 | self.data_stream.write(encoded_frame, *args, **kwargs) 214 | 215 | def read(self, *args, **kwargs): 216 | """Reads APRS-encoded frame from KISS device. 217 | """ 218 | with self.lock: 219 | frame = self.data_stream.read(*args, **kwargs) 220 | if frame is not None and len(frame): 221 | return AprsKiss.__decode_frame(frame) 222 | else: 223 | return None 224 | -------------------------------------------------------------------------------- /src/apex/aprs/constants.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Constants for APRS Module. 6 | """ 7 | 8 | # These imports are for python3 compatibility inside python2 9 | from __future__ import absolute_import 10 | from __future__ import division 11 | from __future__ import print_function 12 | 13 | import logging 14 | 15 | __author__ = 'Jeffrey Phillips Freeman (WI2ARD)' 16 | __maintainer__ = 'Jeffrey Phillips Freeman (WI2ARD)' 17 | __email__ = 'jeffrey.freeman@syncleus.com' 18 | __license__ = 'Apache License, Version 2.0' 19 | __copyright__ = 'Copyright 2016, Syncleus, Inc. and contributors' 20 | __credits__ = [] 21 | 22 | 23 | APRSIS_URL = 'http://srvr.aprs-is.net:8080' 24 | APRSIS_HTTP_HEADERS = { 25 | 'content-type': 'application/octet-stream', 26 | 'accept': 'text/plain' 27 | } 28 | APRSIS_SERVER = 'rotate.aprs.net' 29 | APRSIS_FILTER_PORT = 14580 30 | APRSIS_RX_PORT = 8080 31 | 32 | RECV_BUFFER = 1024 33 | 34 | 35 | LOG_LEVEL = logging.INFO 36 | LOG_FORMAT = logging.Formatter( 37 | ('%(asctime)s %(levelname)s %(name)s.%(funcName)s:%(lineno)d - ' 38 | '%(message)s')) 39 | 40 | GPS_WARM_UP = 5 41 | -------------------------------------------------------------------------------- /src/apex/aprs/decimal_degrees.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | PyDecimalDegrees - geographic coordinates conversion utility. 6 | 7 | Copyright (C) 2006-2013 by Mateusz Łoskot 8 | Copyright (C) 2010-2013 by Evan Wheeler 9 | 10 | This file is part of PyDecimalDegrees module. 11 | 12 | This software is provided 'as-is', without any express or implied warranty. 13 | In no event will the authors be held liable for any damages arising from 14 | the use of this software. 15 | 16 | Permission is granted to anyone to use this software for any purpose, 17 | including commercial applications, and to alter it and redistribute it freely, 18 | subject to the following restrictions: 19 | 1. The origin of this software must not be misrepresented; you must not 20 | claim that you wrote the original software. If you use this software 21 | in a product, an acknowledgment in the product documentation would be 22 | appreciated but is not required. 23 | 2. Altered source versions must be plainly marked as such, and must not be 24 | misrepresented as being the original software. 25 | 3. This notice may not be removed or altered from any source distribution. 26 | 27 | DESCRIPTION 28 | 29 | DecimalDegrees module provides functions to convert between 30 | degrees/minutes/seconds and decimal degrees. 31 | 32 | Original source distribution: 33 | http://mateusz.loskot.net/software/gis/pydecimaldegrees/ 34 | 35 | Inspired by Walter Mankowski's Geo::Coordinates::DecimalDegrees module 36 | for Perl, originally located in CPAN Archives: 37 | http://search.cpan.org/~waltman/Geo-Coordinates-DecimalDegrees-0.05/ 38 | 39 | doctest examples are based following coordinates: 40 | DMS: 121 8' 6" 41 | DM: 121 8.1' 42 | DD: 121.135 43 | 44 | To run doctest units just execut this module script as follows 45 | (-v instructs Python to run script in verbose mode): 46 | 47 | $ python decimal_degrees.py [-v] 48 | 49 | """ 50 | 51 | # These imports are for python3 compatibility inside python2 52 | from __future__ import absolute_import 53 | from __future__ import division 54 | from __future__ import print_function 55 | 56 | import decimal as libdecimal 57 | from decimal import Decimal as D 58 | 59 | import six 60 | 61 | __author__ = 'Jeffrey Phillips Freeman (WI2ARD)' 62 | __maintainer__ = 'Jeffrey Phillips Freeman (WI2ARD)' 63 | __email__ = 'jeffrey.freeman@syncleus.com' 64 | __license__ = 'Apache License, Version 2.0' 65 | __copyright__ = 'Copyright 2016, Syncleus, Inc. and contributors' 66 | __credits__ = [] 67 | 68 | 69 | def decimal2dms(decimal_degrees): 70 | """ Converts a floating point number of degrees to the equivalent 71 | number of degrees, minutes, and seconds, which are returned 72 | as a 3-element tuple of decimals. If 'decimal_degrees' is negative, 73 | only degrees (1st element of returned tuple) will be negative, 74 | minutes (2nd element) and seconds (3rd element) will always be positive. 75 | 76 | Example: 77 | 78 | >>> decimal2dms(121.135) 79 | (Decimal('121'), Decimal('8'), Decimal('6.000')) 80 | >>> decimal2dms(-121.135) 81 | (Decimal('-121'), Decimal('8'), Decimal('6.000')) 82 | 83 | """ 84 | 85 | degrees = D(int(decimal_degrees)) 86 | decimal_minutes = libdecimal.getcontext().multiply( 87 | (D(str(decimal_degrees)) - degrees).copy_abs(), D(60)) 88 | minutes = D(int(decimal_minutes)) 89 | seconds = libdecimal.getcontext().multiply( 90 | (decimal_minutes - minutes), D(60)) 91 | return (degrees, minutes, seconds) 92 | 93 | 94 | def decimal2dm(decimal_degrees): 95 | """ 96 | Converts a floating point number of degrees to the degress & minutes. 97 | 98 | Returns a 2-element tuple of decimals. 99 | 100 | If 'decimal_degrees' is negative, only degrees (1st element of returned 101 | tuple) will be negative, minutes (2nd element) will always be positive. 102 | 103 | Example: 104 | 105 | >>> decimal2dm(121.135) 106 | (Decimal('121'), Decimal('8.100')) 107 | >>> decimal2dm(-121.135) 108 | (Decimal('-121'), Decimal('8.100')) 109 | 110 | """ 111 | degrees = D(int(decimal_degrees)) 112 | 113 | minutes = libdecimal.getcontext().multiply( 114 | (D(str(decimal_degrees)) - degrees).copy_abs(), D(60)) 115 | 116 | return (degrees, minutes) 117 | 118 | 119 | def dms2decimal(degrees, minutes, seconds): 120 | """ Converts degrees, minutes, and seconds to the equivalent 121 | number of decimal degrees. If parameter 'degrees' is negative, 122 | then returned decimal-degrees will also be negative. 123 | 124 | NOTE: this method returns a decimal.Decimal 125 | 126 | Example: 127 | 128 | >>> dms2decimal(121, 8, 6) 129 | Decimal('121.135') 130 | >>> dms2decimal(-121, 8, 6) 131 | Decimal('-121.135') 132 | 133 | """ 134 | decimal = D(0) 135 | degs = D(str(degrees)) 136 | mins = libdecimal.getcontext().divide(D(str(minutes)), D(60)) 137 | secs = libdecimal.getcontext().divide(D(str(seconds)), D(3600)) 138 | 139 | if degrees >= D(0): 140 | decimal = degs + mins + secs 141 | else: 142 | decimal = degs - mins - secs 143 | 144 | return libdecimal.getcontext().normalize(decimal) 145 | 146 | 147 | def dm2decimal(degrees, minutes): 148 | """ Converts degrees and minutes to the equivalent number of decimal 149 | degrees. If parameter 'degrees' is negative, then returned decimal-degrees 150 | will also be negative. 151 | 152 | Example: 153 | 154 | >>> dm2decimal(121, 8.1) 155 | Decimal('121.135') 156 | >>> dm2decimal(-121, 8.1) 157 | Decimal('-121.135') 158 | 159 | """ 160 | return dms2decimal(degrees, minutes, 0) 161 | 162 | 163 | def run_doctest(): # pragma: no cover 164 | """Runs doctests for this module.""" 165 | import doctest 166 | if six.PY2: 167 | import decimaldegrees # pylint: disable=W0406,F0401 168 | return doctest.testmod(decimaldegrees) 169 | 170 | 171 | if __name__ == '__main__': 172 | run_doctest() # pragma: no cover 173 | -------------------------------------------------------------------------------- /src/apex/aprs/igate.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """APRS Internet Service Class Definitions""" 5 | 6 | # These imports are for python3 compatibility inside python2 7 | from __future__ import absolute_import 8 | from __future__ import division 9 | from __future__ import print_function 10 | 11 | import logging 12 | import select 13 | import socket 14 | 15 | import requests 16 | 17 | from apex.aprs import constants as aprs_constants 18 | from apex.aprs import util as aprs_util 19 | 20 | __author__ = 'Jeffrey Phillips Freeman (WI2ARD)' 21 | __maintainer__ = 'Jeffrey Phillips Freeman (WI2ARD)' 22 | __email__ = 'jeffrey.freeman@syncleus.com' 23 | __license__ = 'Apache License, Version 2.0' 24 | __copyright__ = 'Copyright 2016, Syncleus, Inc. and contributors' 25 | __credits__ = [] 26 | 27 | 28 | class IGate(object): 29 | 30 | """APRS Object.""" 31 | 32 | logger = logging.getLogger(__name__) 33 | logger.setLevel(aprs_constants.LOG_LEVEL) 34 | console_handler = logging.StreamHandler() 35 | console_handler.setLevel(aprs_constants.LOG_LEVEL) 36 | console_handler.setFormatter(aprs_constants.LOG_FORMAT) 37 | logger.addHandler(console_handler) 38 | logger.propagate = False 39 | 40 | def __init__(self, user, password='-1', input_url=None): 41 | self.user = user 42 | self._url = input_url or aprs_constants.APRSIS_URL 43 | self._auth = ' '.join( 44 | ['user', user, 'pass', password, 'vers', 'APRS Python Module']) 45 | self.aprsis_sock = None 46 | self.data_buffer = '' 47 | self.packet_buffer = [] 48 | 49 | def __reset_buffer(self): 50 | self.data_buffer = '' 51 | self.packet_buffer = [] 52 | 53 | def connect(self, server=None, port=None, aprs_filter=None): 54 | """ 55 | Connects & logs in to APRS-IS. 56 | 57 | :param server: Optional alternative APRS-IS server. 58 | :param port: Optional APRS-IS port. 59 | :param filter: Optional filter to use. 60 | :type server: str 61 | :type port: int 62 | :type filte: str 63 | """ 64 | if not self.aprsis_sock: 65 | self.__reset_buffer() 66 | 67 | server = server or aprs_constants.APRSIS_SERVER 68 | port = port or aprs_constants.APRSIS_FILTER_PORT 69 | aprs_filter = aprs_filter or '/'.join(['p', self.user]) 70 | 71 | self.full_auth = ' '.join([self._auth, 'filter', aprs_filter]) 72 | 73 | self.server = server 74 | self.port = port 75 | self.aprsis_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 76 | self.aprsis_sock.connect((server, port)) 77 | self.logger.info('Connected to server=%s port=%s', server, port) 78 | self.logger.debug('Sending full_auth=%s', self.full_auth) 79 | self.aprsis_sock.sendall([ord(c) for c in (self.full_auth + '\n\r')]) 80 | 81 | def close(self): 82 | if self.aprsis_sock: 83 | self.aprsis_sock.close() 84 | self.__reset_buffer() 85 | self.aprsis_sock = None 86 | 87 | def write(self, frame_decoded, headers=None, protocol='TCP'): 88 | """ 89 | Sends message to APRS-IS. 90 | 91 | :param message: Message to send to APRS-IS. 92 | :param headers: Optional HTTP headers to post. 93 | :param protocol: Protocol to use: One of TCP, HTTP or UDP. 94 | :type message: str 95 | :type headers: dict 96 | 97 | :return: True on success, False otherwise. 98 | :rtype: bool 99 | """ 100 | 101 | frame = aprs_util.encode_frame(frame_decoded) 102 | if 'TCP' in protocol: 103 | self.aprsis_sock.sendall([ord(c) for c in frame]) 104 | return True 105 | elif 'HTTP' in protocol: 106 | content = '\n'.join([self._auth, frame]) 107 | headers = headers or aprs_constants.APRSIS_HTTP_HEADERS 108 | result = requests.post(self._url, data=content, headers=headers) 109 | return 204 == result.status_code 110 | elif 'UDP' in protocol: 111 | content = '\n'.join([self._auth, frame]) 112 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 113 | sock.sendto( 114 | content, 115 | (aprs_constants.APRSIS_SERVER, aprs_constants.APRSIS_RX_PORT) 116 | ) 117 | return True 118 | 119 | def read(self, filter_logresp=True): 120 | """ 121 | Receives from APRS-IS. 122 | 123 | :param callback: Optional callback to deliver data to. 124 | :type callback: func 125 | """ 126 | # check if there is any data waiting 127 | read_more = True 128 | while read_more: 129 | selected = select.select([self.aprsis_sock], [], [], 0) 130 | if len(selected[0]) > 0: 131 | recvd_data = self.aprsis_sock.recv(aprs_constants.RECV_BUFFER) 132 | if recvd_data: 133 | self.data_buffer += recvd_data 134 | else: 135 | read_more = False 136 | else: 137 | read_more = False 138 | 139 | # check for any complete packets and move them to the packet buffer 140 | if '\r\n' in self.data_buffer: 141 | partial = True 142 | if self.data_buffer.endswith('\r\n'): 143 | partial = False 144 | packets = recvd_data.split('\r\n') 145 | if partial: 146 | self.data_buffer = str(packets.pop(-1)) 147 | else: 148 | self.data_buffer = '' 149 | for packet in packets: 150 | self.packet_buffer += [str(packet)] 151 | 152 | # return the next packet that matches the filter 153 | while len(self.packet_buffer): 154 | packet = self.packet_buffer.pop(0) 155 | if filter_logresp and packet.startswith('#') and 'logresp' in packet: 156 | pass 157 | else: 158 | return aprs_util.decode_frame(packet) 159 | 160 | return None 161 | -------------------------------------------------------------------------------- /src/apex/aprs/util.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Utilities for the APRS Python Module.""" 5 | 6 | # These imports are for python3 compatibility inside python2 7 | from __future__ import absolute_import 8 | from __future__ import division 9 | from __future__ import print_function 10 | 11 | import logging 12 | 13 | import apex.aprs.constants 14 | import apex.aprs.decimal_degrees 15 | import apex.kiss.constants 16 | 17 | __author__ = 'Jeffrey Phillips Freeman (WI2ARD)' 18 | __maintainer__ = 'Jeffrey Phillips Freeman (WI2ARD)' 19 | __email__ = 'jeffrey.freeman@syncleus.com' 20 | __license__ = 'Apache License, Version 2.0' 21 | __copyright__ = 'Copyright 2016, Syncleus, Inc. and contributors' 22 | __credits__ = [] 23 | 24 | 25 | def dec2dm_lat(dec): 26 | """Converts DecDeg to APRS Coord format. 27 | See: http://ember2ash.com/lat.htm 28 | 29 | Source: http://stackoverflow.com/questions/2056750 30 | 31 | Example: 32 | >>> test_lat = 37.7418096 33 | >>> aprs_lat = dec2dm_lat(test_lat) 34 | >>> aprs_lat 35 | '3744.51N' 36 | """ 37 | dec_min = apex.aprs.decimal_degrees.decimal2dm(dec) 38 | 39 | deg = dec_min[0] 40 | abs_deg = abs(deg) 41 | 42 | if not deg == abs_deg: 43 | suffix = 'S' 44 | else: 45 | suffix = 'N' 46 | 47 | return''.join([str(abs_deg), '%.2f' % dec_min[1], suffix]) 48 | 49 | 50 | def dec2dm_lng(dec): 51 | """Converts DecDeg to APRS Coord format. 52 | See: http://ember2ash.com/lat.htm 53 | 54 | Example: 55 | >>> test_lng = -122.38833 56 | >>> aprs_lng = dec2dm_lng(test_lng) 57 | >>> aprs_lng 58 | '12223.30W' 59 | """ 60 | dec_min = apex.aprs.decimal_degrees.decimal2dm(dec) 61 | 62 | deg = dec_min[0] 63 | abs_deg = abs(deg) 64 | 65 | if not deg == abs_deg: 66 | suffix = 'W' 67 | else: 68 | suffix = 'E' 69 | 70 | return ''.join([str(abs_deg), '%.2f' % dec_min[1], suffix]) 71 | 72 | 73 | def decode_frame(frame): 74 | """ 75 | Breaks an ASCII APRS Frame down to it's constituent parts. 76 | 77 | :param frame: ASCII APRS Frame. 78 | :type frame: str 79 | 80 | :returns: Dictionary of APRS Frame parts: source, destination, path, text. 81 | :rtype: dict 82 | """ 83 | logging.debug('frame=%s', frame) 84 | decoded_frame = {} 85 | frame_so_far = '' 86 | 87 | for char in frame: 88 | if '>' in char and 'source' not in decoded_frame: 89 | decoded_frame['source'] = frame_so_far 90 | frame_so_far = '' 91 | elif ':' in char and 'path' not in decoded_frame: 92 | decoded_frame['path'] = frame_so_far 93 | frame_so_far = '' 94 | else: 95 | frame_so_far = ''.join([frame_so_far, char]) 96 | 97 | decoded_frame['text'] = frame_so_far 98 | decoded_frame['destination'] = decoded_frame['path'].split(',')[0] 99 | 100 | return decoded_frame 101 | 102 | 103 | def encode_frame(frame): 104 | """ 105 | Formats APRS frame-as-dict into APRS frame-as-string. 106 | 107 | :param frame: APRS frame-as-dict 108 | :type frame: dict 109 | 110 | :return: APRS frame-as-string. 111 | :rtype: str 112 | """ 113 | formatted_frame = '>'.join([frame['source'], frame['destination']]) 114 | if frame['path']: 115 | formatted_frame = ','.join([formatted_frame, format_path(frame['path'])]) 116 | formatted_frame += ':' 117 | formatted_frame += frame['text'] 118 | return formatted_frame 119 | 120 | 121 | def format_path(path_list): 122 | """ 123 | Formats path from raw APRS KISS frame. 124 | 125 | :param path_list: List of path elements. 126 | :type path_list: list 127 | 128 | :return: Formatted APRS path. 129 | :rtype: str 130 | """ 131 | return ','.join(path_list) 132 | 133 | 134 | def valid_callsign(callsign): 135 | """ 136 | Validates callsign. 137 | 138 | :param callsign: Callsign to validate. 139 | :type callsign: str 140 | 141 | :returns: True if valid, False otherwise. 142 | :rtype: bool 143 | """ 144 | logging.debug('callsign=%s', callsign) 145 | 146 | if '-' in callsign: 147 | if not callsign.count('-') == 1: 148 | return False 149 | else: 150 | callsign, ssid = callsign.split('-') 151 | else: 152 | ssid = 0 153 | 154 | logging.debug('callsign=%s ssid=%s', callsign, ssid) 155 | 156 | if (len(callsign) < 2 or len(callsign) > 6 or len(str(ssid)) < 1 or 157 | len(str(ssid)) > 2): 158 | return False 159 | 160 | for char in callsign: 161 | if not (char.isalpha() or char.isdigit()): 162 | return False 163 | 164 | if not str(ssid).isdigit(): 165 | return False 166 | 167 | if int(ssid) < 0 or int(ssid) > 15: 168 | return False 169 | 170 | return True 171 | 172 | 173 | def run_doctest(): # pragma: no cover 174 | """Runs doctests for this module.""" 175 | import doctest 176 | import apex.aprs.util # pylint: disable=W0406,W0621 177 | return doctest.testmod(apex.aprs.util) 178 | 179 | 180 | def hash_frame(frame): 181 | """ 182 | Produces an integer value that acts as a hash for the frame 183 | :param frame: A frame packet 184 | :type frame: dict 185 | :return: an integer representing the hash 186 | """ 187 | hashing = 0 188 | index = 0 189 | frame_string_prefix = frame['source'] + '>' + frame['destination'] + ':' 190 | for frame_chr in frame_string_prefix: 191 | hashing = ord(frame_chr) << (8*(index % 4)) ^ hashing 192 | index += 1 193 | for char in frame['text']: 194 | hashing = ord(char) << (8*(index % 4)) ^ hashing 195 | index += 1 196 | return hashing 197 | 198 | 199 | if __name__ == '__main__': 200 | run_doctest() # pragma: no cover 201 | -------------------------------------------------------------------------------- /src/apex/buffers.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # These imports are for python3 compatibility inside python2 5 | from __future__ import absolute_import 6 | from __future__ import division 7 | from __future__ import print_function 8 | 9 | import threading 10 | import time 11 | 12 | import cachetools 13 | 14 | from apex.aprs import util as aprs_util 15 | from .util import echo_colorized_frame 16 | from .util import echo_colorized_warning 17 | 18 | 19 | class NonrepeatingBuffer(object): 20 | def __init__(self, base_tnc, base_name, base_port=None, echo_packets=True, buffer_size=10000, buffer_time=30): 21 | self.packet_cache = cachetools.TTLCache(buffer_size, buffer_time) 22 | self.lock = threading.Lock() 23 | self.base_tnc = base_tnc 24 | self.base_port = base_port 25 | self.base_name = base_name 26 | self.echo_packets = echo_packets 27 | 28 | @property 29 | def port(self): 30 | return self.base_port 31 | 32 | @property 33 | def name(self): 34 | return self.base_name 35 | 36 | def connect(self, *args, **kwargs): 37 | self.base_tnc.connect(*args, **kwargs) 38 | 39 | def close(self, *args, **kwargs): 40 | self.base_tnc.close(*args, **kwargs) 41 | 42 | def write(self, frame, *args, **kwargs): 43 | with self.lock: 44 | frame_hash = str(aprs_util.hash_frame(frame)) 45 | if frame_hash not in self.packet_cache: 46 | self.packet_cache[frame_hash] = frame 47 | if self.base_port: 48 | self.base_tnc.write(frame, self.base_port) 49 | else: 50 | self.base_tnc.write(frame) 51 | 52 | if self.echo_packets: 53 | echo_colorized_frame(frame, self.base_name, False) 54 | 55 | def read(self, *args, **kwargs): 56 | with self.lock: 57 | frame = self.base_tnc.read(*args, **kwargs) 58 | if not frame: 59 | return frame 60 | frame_hash = str(aprs_util.hash_frame(frame)) 61 | if frame_hash not in self.packet_cache: 62 | self.packet_cache[frame_hash] = frame 63 | if self.echo_packets: 64 | echo_colorized_frame(frame, self.base_name, True) 65 | return frame 66 | else: 67 | return None 68 | 69 | 70 | class ReconnectingPacketBuffer(object): 71 | 72 | STARTING_WAIT_TIME = 2 73 | MAX_WAIT_TIME = 300 74 | WAIT_TIME_MULTIPLIER = 2 75 | MAX_INDEX = 1000000 76 | 77 | def __init__(self, packet_layer): 78 | self.packet_layer = packet_layer 79 | self.to_packet_layer = cachetools.TTLCache(10, 30) 80 | self.current_index = 0 81 | self.from_packet_layer = cachetools.TTLCache(10, 30) 82 | self.connect_thread = None 83 | self.lock = threading.Lock() 84 | self.running = False 85 | self.reconnect_wait_time = self.STARTING_WAIT_TIME 86 | self.last_connect_attempt = None 87 | self.connect_args = None 88 | self.connect_kwargs = None 89 | self.connected = False 90 | 91 | def __increment_wait_time(self): 92 | self.reconnect_wait_time *= self.WAIT_TIME_MULTIPLIER 93 | if self.reconnect_wait_time > self.MAX_WAIT_TIME: 94 | self.reconnect_wait_time = self.MAX_WAIT_TIME 95 | 96 | def __reset_wait_time(self): 97 | self.reconnect_wait_time = self.STARTING_WAIT_TIME 98 | 99 | def __run(self): 100 | while self.running: 101 | if not self.connected: 102 | if not self.last_connect_attempt or time.time() - self.last_connect_attempt > self.reconnect_wait_time: 103 | try: 104 | self.last_connect_attempt = time.time() 105 | self.packet_layer.connect(*self.connect_args, **self.connect_kwargs) 106 | self.connected = True 107 | except IOError: 108 | echo_colorized_warning('Could not connect, will reattempt.') 109 | try: 110 | self.packet_layer.close() 111 | except IOError: 112 | pass 113 | self.__increment_wait_time() 114 | else: 115 | time.sleep(1) 116 | else: 117 | io_occured = False 118 | 119 | # lets attempt to read in a packet 120 | try: 121 | read_packet = self.packet_layer.read() 122 | self.__reset_wait_time() 123 | if read_packet: 124 | with self.lock: 125 | self.from_packet_layer[str(aprs_util.hash_frame(read_packet))] = read_packet 126 | io_occured = True 127 | except IOError: 128 | echo_colorized_warning('Read failed. Will disconnect and attempt to reconnect.') 129 | try: 130 | self.packet_layer.close() 131 | except IOError: 132 | pass 133 | self.connected = False 134 | continue 135 | 136 | # lets try to write a packet, if any are waiting. 137 | write_packet = None 138 | with self.lock: 139 | if self.to_packet_layer: 140 | write_packet = self.to_packet_layer.popitem()[1] 141 | if write_packet: 142 | try: 143 | self.packet_layer.write(write_packet) 144 | io_occured = True 145 | self.__reset_wait_time() 146 | except IOError: 147 | echo_colorized_warning('Write failed. Will disconnect and attempt to reconnect.') 148 | self.to_packet_layer[str(aprs_util.hash_frame(read_packet))] = write_packet 149 | try: 150 | self.packet_layer.close() 151 | except IOError: 152 | pass 153 | self.connected = False 154 | continue 155 | 156 | if not io_occured: 157 | time.sleep(1) 158 | try: 159 | self.packet_layer.close() 160 | except IOError: 161 | pass 162 | 163 | def connect(self, *args, **kwargs): 164 | with self.lock: 165 | if self.connect_thread: 166 | raise RuntimeError('already connected') 167 | 168 | self.running = True 169 | self.connect_args = args 170 | self.connect_kwargs = kwargs 171 | self.connect_thread = threading.Thread(target=self.__run) 172 | self.connect_thread.start() 173 | 174 | def close(self): 175 | with self.lock: 176 | if not self.connect_thread: 177 | raise RuntimeError('not connected') 178 | 179 | self.running = False 180 | self.connect_thread.join() 181 | self.connect_thread = None 182 | 183 | def read(self): 184 | with self.lock: 185 | if self.from_packet_layer: 186 | return self.from_packet_layer.popitem()[1] 187 | return None 188 | 189 | def write(self, packet): 190 | with self.lock: 191 | self.to_packet_layer[str(aprs_util.hash_frame(packet))] = packet 192 | -------------------------------------------------------------------------------- /src/apex/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Module that contains the command line app. 6 | 7 | Why does this file exist, and why not put this in __main__? 8 | 9 | You might be tempted to import things from __main__ later, but that will cause 10 | problems: the code will get executed twice: 11 | 12 | - When you run `python -mapex` python will execute 13 | ``__main__.py`` as a script. That means there won't be any 14 | ``apex.__main__`` in ``sys.modules``. 15 | - When you import __main__ it will get executed again (as a module) because 16 | there's no ``apex.__main__`` in ``sys.modules``. 17 | 18 | Also see (1) from http://click.pocoo.org/5/setuptools/#setuptools-integration 19 | """ 20 | 21 | # These imports are for python3 compatibility inside python2 22 | from __future__ import absolute_import 23 | from __future__ import division 24 | from __future__ import print_function 25 | 26 | import os 27 | import signal 28 | import sys 29 | import threading 30 | import time 31 | 32 | import click 33 | import six 34 | 35 | import apex.aprs 36 | import apex.buffers 37 | from apex.kiss import constants as kissConstants 38 | from apex.plugin_loader import get_plugins 39 | from apex.plugin_loader import load_plugin 40 | 41 | from .buffers import NonrepeatingBuffer 42 | from .util import echo_colorized_error 43 | from .util import echo_colorized_warning 44 | 45 | configparser = None 46 | if six.PY2: 47 | import ConfigParser # noqa: F401 48 | if configparser is None: 49 | configparser = ConfigParser 50 | elif six.PY3: 51 | import configparser 52 | 53 | __author__ = 'Jeffrey Phillips Freeman (WI2ARD)' 54 | __maintainer__ = 'Jeffrey Phillips Freeman (WI2ARD)' 55 | __email__ = 'jeffrey.freeman@syncleus.com' 56 | __license__ = 'Apache License, Version 2.0' 57 | __copyright__ = 'Copyright 2016, Syncleus, Inc. and contributors' 58 | __credits__ = [] 59 | 60 | config = None 61 | aprsis = None 62 | port_map = {} 63 | running = True 64 | plugin_modules = [] 65 | plugin_threads = [] 66 | 67 | 68 | def sigint_handler(signal=None, frame=None): 69 | global running 70 | running = False 71 | 72 | click.echo() 73 | click.echo('SIGINT caught, shutting down..') 74 | 75 | for plugin_module in plugin_modules: 76 | plugin_module.stop() 77 | if aprsis: 78 | aprsis.close() 79 | # Lets wait until all the plugins successfully end 80 | for plugin_thread in plugin_threads: 81 | plugin_thread.join() 82 | for port in port_map.values(): 83 | port['tnc'].close() 84 | 85 | click.echo('APEX successfully shutdown.') 86 | sys.exit(0) 87 | 88 | 89 | def find_config(config_paths, verbose): 90 | config_file = 'apex.conf' 91 | rc_file = '.apexrc' 92 | cur_path = os.path.join(os.curdir, config_file) 93 | home_path = os.path.join(os.path.expanduser('~'), rc_file) 94 | etc_path = os.path.sep + os.path.join('etc', config_file) 95 | if config_paths is None: 96 | if os.name == 'posix': 97 | config_paths = [cur_path, home_path, etc_path] 98 | else: 99 | config_paths = [cur_path, home_path] 100 | elif isinstance(config_paths, str): 101 | config_paths = [config_paths] 102 | elif not isinstance(config_paths, list): 103 | raise TypeError('config_paths argument was neither a string nor a list') 104 | 105 | if verbose: 106 | click.echo('Searching for configuration file in the following locations: %s' % repr(config_paths)) 107 | 108 | config = configparser.ConfigParser() 109 | for config_path in config_paths: 110 | try: 111 | if len(config.read(config_path)) > 0: 112 | return config 113 | except IOError: 114 | pass 115 | 116 | return None 117 | 118 | 119 | def configure(configfile, verbose=False): 120 | global config 121 | config = find_config(configfile, verbose) 122 | if config is None: 123 | echo_colorized_error('No apex configuration found, can not continue.') 124 | return False 125 | for section in config.sections(): 126 | if section.startswith("TNC "): 127 | tnc_name = section.strip().split(" ")[1].strip() 128 | if tnc_name is 'IGATE': 129 | echo_colorized_error('IGATE was used as the name for a TNC in the configuration, this name is reserved') 130 | return False 131 | if config.has_option(section, 'com_port') and config.has_option(section, 'baud'): 132 | com_port = config.get(section, 'com_port') 133 | baud = config.get(section, 'baud') 134 | kiss_tnc = apex.buffers.ReconnectingPacketBuffer(apex.aprs.AprsKiss(apex.kiss.KissSerial(com_port=com_port, baud=baud))) 135 | elif config.has_option(section, 'tcp_host') and config.has_option(section, 'tcp_port'): 136 | tcp_host = config.get(section, 'tcp_host') 137 | tcp_port = config.get(section, 'tcp_port') 138 | kiss_tnc = apex.buffers.ReconnectingPacketBuffer(apex.aprs.AprsKiss(apex.kiss.KissTcp(host=tcp_host, tcp_port=tcp_port))) 139 | else: 140 | echo_colorized_error("""Invalid configuration, must have both com_port and baud set or tcp_host and 141 | tcp_port set in TNC sections of configuration file""") 142 | return False 143 | 144 | if not config.has_option(section, 'kiss_init'): 145 | echo_colorized_error('Invalid configuration, must have kiss_init set in TNC sections of configuration file') 146 | return False 147 | kiss_init_string = config.get(section, 'kiss_init') 148 | if kiss_init_string == 'MODE_INIT_W8DED': 149 | kiss_tnc.connect(kissConstants.MODE_INIT_W8DED) 150 | elif kiss_init_string == 'MODE_INIT_KENWOOD_D710': 151 | kiss_tnc.connect(kissConstants.MODE_INIT_KENWOOD_D710) 152 | elif kiss_init_string == 'NONE': 153 | kiss_tnc.connect() 154 | else: 155 | echo_colorized_error('Invalid configuration, value assigned to kiss_init was not recognized: %s' 156 | % kiss_init_string) 157 | return False 158 | 159 | for port in range(1, 1 + int(config.get(section, 'port_count'))): 160 | port_name = tnc_name + '-' + str(port) 161 | port_section = 'PORT ' + port_name 162 | port_identifier = config.get(port_section, 'identifier') 163 | port_net = config.get(port_section, 'net') 164 | tnc_port = int(config.get(port_section, 'tnc_port')) 165 | port_map[port_name] = {'identifier': port_identifier, 166 | 'net': port_net, 167 | 'tnc': NonrepeatingBuffer(kiss_tnc, port_name, tnc_port), 168 | 'tnc_port': tnc_port} 169 | 170 | global aprsis 171 | aprsis = None 172 | if config.has_section('IGATE'): 173 | aprsis_callsign = config.get('IGATE', 'callsign') 174 | if config.has_option('IGATE', 'password'): 175 | aprsis_password = config.get('IGATE', 'password') 176 | else: 177 | aprsis_password = -1 178 | aprsis_server = config.get('IGATE', 'server') 179 | aprsis_server_port = config.get('IGATE', 'server_port') 180 | aprsis_base = apex.buffers.ReconnectingPacketBuffer(apex.aprs.IGate(aprsis_callsign, aprsis_password)) 181 | aprsis = NonrepeatingBuffer(aprsis_base, 'IGATE') 182 | aprsis.connect(aprsis_server, int(aprsis_server_port)) 183 | 184 | return True 185 | 186 | 187 | @click.command(context_settings=dict(auto_envvar_prefix='APEX')) 188 | @click.option('-c', 189 | '--configfile', 190 | type=click.Path(exists=True, file_okay=True, dir_okay=False, readable=True, resolve_path=True), 191 | help='Configuration file for APEX.') 192 | @click.option('-v', '--verbose', is_flag=True, help='Enables verbose mode.') 193 | def main(verbose, configfile): 194 | # load the configuration, if it fails, exit 195 | if not configure(configfile, verbose): 196 | return 197 | 198 | click.echo("Press ctrl + c at any time to exit") 199 | 200 | # start the plugins 201 | try: 202 | plugin_loaders = get_plugins() 203 | if not len(plugin_loaders): 204 | echo_colorized_warning('No plugins were able to be discovered, will only display incoming messages.') 205 | for plugin_loader in plugin_loaders: 206 | if verbose: 207 | click.echo('Plugin found at the following location: %s' % repr(plugin_loader)) 208 | loaded_plugin = load_plugin(plugin_loader) 209 | plugin_modules.append(loaded_plugin) 210 | new_thread = threading.Thread(target=loaded_plugin.start, args=(config, port_map, aprsis)) 211 | new_thread.start() 212 | plugin_threads.append(new_thread) 213 | except IOError: 214 | echo_colorized_warning('plugin directory not found, will only display incoming messages.') 215 | 216 | signal.signal(signal.SIGINT, sigint_handler) 217 | 218 | # process all incoming frames by sending them to each of the plugins. 219 | if verbose: 220 | click.echo('Starting packet processing...') 221 | while running: 222 | something_read = False 223 | for port_name in port_map.keys(): 224 | port = port_map[port_name] 225 | frame = port['tnc'].read() 226 | if frame: 227 | for plugin_module in plugin_modules: 228 | something_read = True 229 | plugin_module.handle_packet(frame, port, port_name) 230 | 231 | if something_read is False: 232 | time.sleep(1) 233 | -------------------------------------------------------------------------------- /src/apex/kiss/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # KISS Python Module. 5 | 6 | """ 7 | KISS Python Module. 8 | ~~~~ 9 | 10 | 11 | :author: Jeffrey Phillips Freeman WI2ARD 12 | :copyright: Copyright 2016 Syncleus, Inc. and contributors 13 | :license: Apache License, Version 2.0 14 | :source: 15 | 16 | """ 17 | 18 | # These imports are for python3 compatibility inside python2 19 | from __future__ import absolute_import 20 | from __future__ import division 21 | from __future__ import print_function 22 | 23 | import logging 24 | from .kiss import Kiss # noqa: F401 25 | from .kiss_serial import KissSerial # noqa: F401 26 | from .kiss_tcp import KissTcp # noqa: F401 27 | 28 | __author__ = 'Jeffrey Phillips Freeman (WI2ARD)' 29 | __maintainer__ = 'Jeffrey Phillips Freeman (WI2ARD)' 30 | __email__ = 'jeffrey.freeman@syncleus.com' 31 | __license__ = 'Apache License, Version 2.0' 32 | __copyright__ = 'Copyright 2016, Syncleus, Inc. and contributors' 33 | __credits__ = [] 34 | __version__ = '0.0.6' 35 | 36 | 37 | # Set default logging handler to avoid "No handler found" warnings. 38 | try: # Python 2.7+ 39 | from logging import NullHandler 40 | except ImportError: 41 | class NullHandler(logging.Handler): 42 | """Default logging handler to avoid "No handler found" warnings.""" 43 | def emit(self, record): 44 | """Default logging handler to avoid "No handler found" warnings.""" 45 | pass 46 | 47 | logging.getLogger(__name__).addHandler(NullHandler()) 48 | -------------------------------------------------------------------------------- /src/apex/kiss/constants.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Constants for KISS Python Module.""" 5 | 6 | # These imports are for python3 compatibility inside python2 7 | from __future__ import absolute_import 8 | from __future__ import division 9 | from __future__ import print_function 10 | 11 | import logging 12 | 13 | __author__ = 'Jeffrey Phillips Freeman (WI2ARD)' 14 | __maintainer__ = 'Jeffrey Phillips Freeman (WI2ARD)' 15 | __email__ = 'jeffrey.freeman@syncleus.com' 16 | __license__ = 'Apache License, Version 2.0' 17 | __copyright__ = 'Copyright 2016, Syncleus, Inc. and contributors' 18 | __credits__ = [] 19 | 20 | 21 | LOG_LEVEL = logging.DEBUG 22 | LOG_FORMAT = ('%(asctime)s %(levelname)s %(name)s.%(funcName)s:%(lineno)d' 23 | ' - %(message)s') 24 | 25 | SERIAL_TIMEOUT = 0.01 26 | READ_BYTES = 1000 27 | 28 | # KISS Special Characters 29 | # http://en.wikipedia.org/wiki/KISS_(TNC)#Special_Characters 30 | FEND = 0xC0 31 | FESC = 0xDB 32 | TFEND = 0xDC 33 | TFESC = 0xDD 34 | 35 | # "FEND is sent as FESC, TFEND" 36 | FESC_TFEND = [FESC] + [TFEND] 37 | 38 | # "FESC is sent as FESC, TFESC" 39 | FESC_TFESC = [FESC] + [TFESC] 40 | 41 | # KISS Command Codes 42 | # http://en.wikipedia.org/wiki/KISS_(TNC)#Command_Codes 43 | DATA_FRAME = 0x00 44 | TX_DELAY = 0x01 45 | PERSISTENCE = 0x02 46 | SLOT_TIME = 0x03 47 | TX_TAIL = 0x04 48 | FULL_DUPLEX = 0x05 49 | SET_HARDWARE = 0x06 50 | RETURN = 0xFF 51 | 52 | DEFAULT_KISS_CONFIG_VALUES = { 53 | 'TX_DELAY': 40, 54 | 'PERSISTENCE': 63, 55 | 'SLOT_TIME': 20, 56 | 'TX_TAIL': 30, 57 | 'FULL_DUPLEX': 0, 58 | } 59 | 60 | # This command will exit KISS mode 61 | MODE_END = [192, 255, 192, 13] 62 | 63 | # This will start kiss on a WA8DED or LINK>. 127 or port < 0: 96 | raise Exception('port out of range') 97 | elif command_code > 127 or command_code < 0: 98 | raise Exception('command_Code out of range') 99 | return (port << 4) & command_code 100 | 101 | @abstractmethod 102 | def _read_interface(self): 103 | pass 104 | 105 | @abstractmethod 106 | def _write_interface(self, data): 107 | pass 108 | 109 | def connect(self, mode_init=None, **kwargs): 110 | """ 111 | Initializes the KISS device and commits configuration. 112 | 113 | This method is abstract and must be implemented by a concrete class. 114 | 115 | See http://en.wikipedia.org/wiki/KISS_(TNC)#Command_codes 116 | for configuration names. 117 | 118 | :param **kwargs: name/value pairs to use as initial config values. 119 | """ 120 | pass 121 | 122 | def close(self): 123 | if self.exit_kiss is True: 124 | self._write_interface(kiss_constants.MODE_END) 125 | 126 | def _write_setting(self, name, value): 127 | """ 128 | Writes KISS Command Codes to attached device. 129 | 130 | http://en.wikipedia.org/wiki/KISS_(TNC)#Command_Codes 131 | 132 | :param name: KISS Command Code Name as a string. 133 | :param value: KISS Command Code Value to write. 134 | """ 135 | self.logger.debug('Configuring %s = %s', name, repr(value)) 136 | 137 | # Do the reasonable thing if a user passes an int 138 | if isinstance(value, int): 139 | value = chr(value) 140 | 141 | return self._write_interface( 142 | [kiss_constants.FEND] + 143 | [getattr(kiss_constants, name.upper())] + 144 | [Kiss.__escape_special_codes(value)] + 145 | [kiss_constants.FEND] 146 | ) 147 | 148 | def __fill_buffer(self): 149 | """ 150 | Reads any pending data in the interface and stores it in the frame_buffer 151 | """ 152 | 153 | new_frames = [] 154 | read_buffer = [] 155 | read_data = self._read_interface() 156 | while read_data is not None and len(read_data): 157 | split_data = [[]] 158 | for read_byte in read_data: 159 | if read_byte is kiss_constants.FEND: 160 | split_data.append([]) 161 | else: 162 | split_data[-1].append(read_byte) 163 | len_fend = len(split_data) 164 | 165 | # No FEND in frame 166 | if len_fend == 1: 167 | read_buffer += split_data[0] 168 | # Single FEND in frame 169 | elif len_fend == 2: 170 | # Closing FEND found 171 | if split_data[0]: 172 | # Partial frame continued, otherwise drop 173 | new_frames.append(read_buffer + split_data[0]) 174 | read_buffer = [] 175 | # Opening FEND found 176 | else: 177 | new_frames.append(read_buffer) 178 | read_buffer = split_data[1] 179 | # At least one complete frame received 180 | elif len_fend >= 3: 181 | for i in range(0, len_fend - 1): 182 | read_buffer_tmp = read_buffer + split_data[i] 183 | if len(read_buffer_tmp) is not 0: 184 | new_frames.append(read_buffer_tmp) 185 | read_buffer = [] 186 | if split_data[len_fend - 1]: 187 | read_buffer = split_data[len_fend - 1] 188 | # Get anymore data that is waiting 189 | read_data = self._read_interface() 190 | 191 | for new_frame in new_frames: 192 | if len(new_frame) and new_frame[0] == 0: 193 | if self.strip_df_start: 194 | new_frame = Kiss.__strip_df_start(new_frame) 195 | self.frame_buffer.append(new_frame) 196 | 197 | def read(self): 198 | with self.lock: 199 | if not len(self.frame_buffer): 200 | self.__fill_buffer() 201 | 202 | if len(self.frame_buffer): 203 | return_frame = self.frame_buffer[0] 204 | del self.frame_buffer[0] 205 | return return_frame 206 | else: 207 | return None 208 | 209 | def write(self, frame_bytes, port=0): 210 | """ 211 | Writes frame to KISS interface. 212 | 213 | :param frame: Frame to write. 214 | """ 215 | with self.lock: 216 | kiss_packet = [kiss_constants.FEND] + [Kiss.__command_byte_combine(port, kiss_constants.DATA_FRAME)] + \ 217 | Kiss.__escape_special_codes(frame_bytes) + [kiss_constants.FEND] 218 | 219 | return self._write_interface(kiss_packet) 220 | -------------------------------------------------------------------------------- /src/apex/kiss/kiss_serial.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """KISS Core Classes.""" 5 | 6 | # These imports are for python3 compatibility inside python2 7 | from __future__ import absolute_import 8 | from __future__ import division 9 | from __future__ import print_function 10 | 11 | import logging 12 | import serial 13 | 14 | import six 15 | 16 | from apex.kiss import constants as kiss_constants 17 | 18 | from .kiss import Kiss 19 | 20 | __author__ = 'Jeffrey Phillips Freeman (WI2ARD)' 21 | __maintainer__ = 'Jeffrey Phillips Freeman (WI2ARD)' 22 | __email__ = 'jeffrey.freeman@syncleus.com' 23 | __license__ = 'Apache License, Version 2.0' 24 | __copyright__ = 'Copyright 2016, Syncleus, Inc. and contributors' 25 | __credits__ = [] 26 | 27 | 28 | class KissSerial(Kiss): 29 | 30 | """KISS Serial Object Class.""" 31 | 32 | logger = logging.getLogger(__name__) 33 | logger.setLevel(kiss_constants.LOG_LEVEL) 34 | console_handler = logging.StreamHandler() 35 | console_handler.setLevel(kiss_constants.LOG_LEVEL) 36 | formatter = logging.Formatter(kiss_constants.LOG_FORMAT) 37 | console_handler.setFormatter(formatter) 38 | logger.addHandler(console_handler) 39 | logger.propagate = False 40 | 41 | def __init__(self, strip_df_start=True, 42 | com_port=None, 43 | baud=38400, 44 | parity=serial.PARITY_NONE, 45 | stop_bits=serial.STOPBITS_ONE, 46 | byte_size=serial.EIGHTBITS): 47 | super(KissSerial, self).__init__(strip_df_start) 48 | 49 | self.com_port = com_port 50 | self.baud = baud 51 | self.parity = parity 52 | self.stop_bits = stop_bits 53 | self.byte_size = byte_size 54 | self.serial = None 55 | 56 | self.logger.info('Using interface_mode=Serial') 57 | 58 | def __enter__(self): 59 | return self 60 | 61 | def __exit__(self, exc_type, exc_val, exc_tb): 62 | self.serial.close() 63 | 64 | def __del__(self): 65 | if self.serial and self.serial.isOpen(): 66 | self.serial.close() 67 | 68 | def _read_interface(self): 69 | read_data = self.serial.read(kiss_constants.READ_BYTES) 70 | waiting_data = self.serial.inWaiting() 71 | if waiting_data: 72 | read_data += self.serial.read(waiting_data) 73 | return [ord(c) if six.PY2 else c for c in read_data] 74 | 75 | def _write_interface(self, data): 76 | self.serial.write(data) 77 | 78 | def connect(self, mode_init=None, **kwargs): 79 | """ 80 | Initializes the KISS device and commits configuration. 81 | 82 | See http://en.wikipedia.org/wiki/KISS_(TNC)#Command_codes 83 | for configuration names. 84 | 85 | :param **kwargs: name/value pairs to use as initial config values. 86 | """ 87 | self.logger.debug('kwargs=%s', kwargs) 88 | 89 | self.serial = serial.Serial(port=self.com_port, baudrate=self.baud, parity=self.parity, 90 | stopbits=self.stop_bits, bytesize=self.byte_size) 91 | self.serial.timeout = kiss_constants.SERIAL_TIMEOUT 92 | if mode_init is not None: 93 | self.serial.write(mode_init) 94 | self.exit_kiss = True 95 | else: 96 | self.exit_kiss = False 97 | 98 | # Previous verious defaulted to Xastir-friendly configs. Unfortunately 99 | # those don't work with Bluetooth TNCs, so we're reverting to None. 100 | if kwargs: 101 | for name, value in kwargs.items(): 102 | super(KissSerial, self)._write_setting(name, value) 103 | 104 | def close(self): 105 | super(KissSerial, self).close() 106 | 107 | if not self.serial: 108 | raise RuntimeError('Attempting to close before the class has been started.') 109 | elif self.serial.isOpen(): 110 | self.serial.close() 111 | -------------------------------------------------------------------------------- /src/apex/kiss/kiss_tcp.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """KISS Core Classes.""" 5 | 6 | # These imports are for python3 compatibility inside python2 7 | from __future__ import absolute_import 8 | from __future__ import division 9 | from __future__ import print_function 10 | 11 | import logging 12 | import socket 13 | 14 | from apex.kiss import constants as kiss_constants 15 | from .kiss import Kiss 16 | 17 | __author__ = 'Jeffrey Phillips Freeman (WI2ARD)' 18 | __maintainer__ = 'Jeffrey Phillips Freeman (WI2ARD)' 19 | __email__ = 'jeffrey.freeman@syncleus.com' 20 | __license__ = 'Apache License, Version 2.0' 21 | __copyright__ = 'Copyright 2016, Syncleus, Inc. and contributors' 22 | __credits__ = [] 23 | __version__ = '0.0.2' 24 | 25 | 26 | class KissTcp(Kiss): 27 | 28 | """KISS TCP Object Class.""" 29 | 30 | logger = logging.getLogger(__name__) 31 | logger.setLevel(kiss_constants.LOG_LEVEL) 32 | console_handler = logging.StreamHandler() 33 | console_handler.setLevel(kiss_constants.LOG_LEVEL) 34 | formatter = logging.Formatter(kiss_constants.LOG_FORMAT) 35 | console_handler.setFormatter(formatter) 36 | logger.addHandler(console_handler) 37 | logger.propagate = False 38 | 39 | def __init__(self, 40 | strip_df_start=True, 41 | host=None, 42 | tcp_port=8000): 43 | super(KissTcp, self).__init__(strip_df_start) 44 | 45 | self.host = host 46 | self.tcp_port = tcp_port 47 | self.socket = None 48 | 49 | self.logger.info('Using interface_mode=TCP') 50 | 51 | def __enter__(self): 52 | return self 53 | 54 | def __exit__(self, exc_type, exc_val, exc_tb): 55 | self.socket.close() 56 | 57 | def _read_interface(self): 58 | return self.socket.recv(kiss_constants.READ_BYTES) 59 | 60 | def _write_interface(self, data): 61 | self.socket.write(data) 62 | 63 | def connect(self, mode_init=None, **kwargs): 64 | """ 65 | Initializes the KISS device and commits configuration. 66 | 67 | See http://en.wikipedia.org/wiki/KISS_(TNC)#Command_codes 68 | for configuration names. 69 | 70 | :param **kwargs: name/value pairs to use as initial config values. 71 | """ 72 | self.logger.debug('kwargs=%s', kwargs) 73 | 74 | address = (self.host, self.tcp_port) 75 | self.socket = socket.create_connection(address) 76 | 77 | def close(self): 78 | super(KissTcp, self).close() 79 | 80 | if not self.socket: 81 | raise RuntimeError('Attempting to close before the class has been started.') 82 | 83 | self.socket.shutdown() 84 | self.socket.close() 85 | 86 | def shutdown(self): 87 | self.socket.shutdown() 88 | -------------------------------------------------------------------------------- /src/apex/plugin_loader.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # These imports are for python3 compatibility inside python2 5 | from __future__ import absolute_import 6 | from __future__ import division 7 | from __future__ import print_function 8 | 9 | import importlib 10 | 11 | __author__ = 'Jeffrey Phillips Freeman (WI2ARD)' 12 | __maintainer__ = 'Jeffrey Phillips Freeman (WI2ARD)' 13 | __email__ = 'jeffrey.freeman@syncleus.com' 14 | __license__ = 'Apache License, Version 2.0' 15 | __copyright__ = 'Copyright 2016, Syncleus, Inc. and contributors' 16 | __credits__ = [] 17 | 18 | MainModule = '__init__' 19 | 20 | plugin_modules = ['apex.plugins.apexparadigm', 21 | 'apex.plugins.beacon', 22 | 'apex.plugins.id', 23 | 'apex.plugins.status'] 24 | 25 | 26 | def get_plugins(): 27 | return plugin_modules 28 | 29 | 30 | def load_plugin(plugin): 31 | return importlib.import_module(plugin) 32 | -------------------------------------------------------------------------------- /src/apex/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # These imports are for python3 compatibility inside python2 5 | from __future__ import absolute_import 6 | from __future__ import division 7 | from __future__ import print_function 8 | 9 | __author__ = 'Jeffrey Phillips Freeman (WI2ARD)' 10 | __maintainer__ = 'Jeffrey Phillips Freeman (WI2ARD)' 11 | __email__ = 'jeffrey.freeman@syncleus.com' 12 | __license__ = 'Apache License, Version 2.0' 13 | __copyright__ = 'Copyright 2016, Syncleus, Inc. and contributors' 14 | __credits__ = [] 15 | __version__ = '0.0.6' 16 | -------------------------------------------------------------------------------- /src/apex/plugins/apexparadigm/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # These imports are for python3 compatibility inside python2 5 | from __future__ import absolute_import 6 | from __future__ import division 7 | from __future__ import print_function 8 | 9 | import copy 10 | import re 11 | 12 | import apex.routing 13 | 14 | __author__ = 'Jeffrey Phillips Freeman (WI2ARD)' 15 | __maintainer__ = 'Jeffrey Phillips Freeman (WI2ARD)' 16 | __email__ = 'jeffrey.freeman@syncleus.com' 17 | __license__ = 'Apache License, Version 2.0' 18 | __copyright__ = 'Copyright 2016, Syncleus, Inc. and contributors' 19 | __credits__ = [] 20 | __version__ = '0.0.6' 21 | 22 | plugin = None 23 | 24 | 25 | def start(config, port_map, aprsis): 26 | global plugin 27 | plugin = ApexParadigmPlugin(config, port_map, aprsis) 28 | plugin.run() 29 | 30 | 31 | def handle_packet(frame, recv_port, recv_port_name): 32 | plugin.handle_packet(frame, recv_port, recv_port_name) 33 | 34 | 35 | def stop(): 36 | plugin.stop() 37 | 38 | 39 | class ApexParadigmPlugin(object): 40 | 41 | BAND_PATH_REGEX = re.compile(r'(\d{1,4})M(\d{0,3})') 42 | 43 | def __init__(self, config, port_map, aprsis): 44 | self.port_map = port_map 45 | self.aprsis = aprsis 46 | 47 | def __passive_digipeat(self, frame, recv_port, recv_port_name): 48 | # can't digipeat packets if we are in the expended path 49 | if apex.routing.has_seen(self.port_map, frame): 50 | return 51 | 52 | for hop_index in range(0, len(frame['path'])): 53 | hop = frame['path'][hop_index] 54 | if not apex.routing.is_hop_consumed(hop): 55 | split_hop = hop.split('-') 56 | node = split_hop[0].upper() 57 | if len(split_hop) >= 2 and split_hop[1]: 58 | ssid = int(split_hop[1]) 59 | else: 60 | ssid = 0 61 | 62 | band_path = None 63 | band_path_net = None 64 | band_match = self.BAND_PATH_REGEX.match(node) 65 | if band_match is not None: 66 | band_path = band_match.group(1) 67 | band_path_net = band_match.group(2) 68 | 69 | for port_name in self.port_map.keys(): 70 | port = self.port_map[port_name] 71 | split_port_identifier = port['identifier'].split('-') 72 | port_callsign = split_port_identifier[0].upper() 73 | if len(split_port_identifier) >= 2 and split_port_identifier[1]: 74 | port_ssid = int(split_port_identifier[1]) 75 | else: 76 | port_ssid = 0 77 | 78 | if band_path: 79 | if band_path_net: 80 | if node == port['net']: 81 | frame['path'] = frame['path'][:hop_index] + [recv_port['identifier'] + '*'] +\ 82 | [hop + '*'] + frame['path'][hop_index+1:] 83 | port['tnc'].write(frame, port['tnc_port']) 84 | self.aprsis.write(frame) 85 | return 86 | else: 87 | if port['net'].startswith(node): 88 | frame['path'] = frame['path'][:hop_index] + [recv_port['identifier'] + '*'] +\ 89 | [hop + '*'] + frame['path'][hop_index+1:] 90 | port['tnc'].write(frame, port['tnc_port']) 91 | self.aprsis.write(frame) 92 | return 93 | if node == port_callsign and ssid == port_ssid: 94 | if ssid is 0: 95 | frame['path'][hop_index] = port_callsign + '*' 96 | else: 97 | frame['path'][hop_index] = port['identifier'] + '*' 98 | port['tnc'].write(frame, port['tnc_port']) 99 | self.aprsis.write(frame) 100 | return 101 | elif node == 'GATE' and port['net'].startswith('2M'): 102 | frame['path'] = frame['path'][:hop_index] + [recv_port['identifier'] + '*'] + [node + '*'] +\ 103 | frame['path'][hop_index+1:] 104 | port['tnc'].write(frame, port['tnc_port']) 105 | self.aprsis.write(frame) 106 | return 107 | if node.startswith('WIDE') and ssid > 1: 108 | frame['path'] = frame['path'][:hop_index] + [recv_port['identifier'] + '*'] +\ 109 | [node + '-' + str(ssid-1)] + frame['path'][hop_index+1:] 110 | port['tnc'].write(frame, port['tnc_port']) 111 | self.aprsis.write(frame) 112 | return 113 | elif node.startswith('WIDE') and ssid is 1: 114 | frame['path'] = frame['path'][:hop_index] + [recv_port['identifier'] + '*'] + [node + '*'] + frame['path'][hop_index+1:] 115 | port['tnc'].write(frame, port['tnc_port']) 116 | self.aprsis.write(frame) 117 | return 118 | elif node.startswith('WIDE') and ssid is 0: 119 | frame['path'][hop_index] = node + '*' 120 | # no return 121 | else: 122 | # If we didnt digipeat it then we didn't modify the frame, send it to aprsis as-is 123 | self.aprsis.write(frame) 124 | return 125 | 126 | def __preemptive_digipeat(self, frame, recv_port, recv_port_name): 127 | # can't digipeat packets if we are in the expended path 128 | if apex.routing.has_seen(self.port_map, frame): 129 | return 130 | 131 | selected_hop = {} 132 | node = None 133 | ssid = 0 134 | for hop_index in reversed(range(0, len(frame['path']))): 135 | hop = frame['path'][hop_index] 136 | # If this is the last node before a spent node, or a spent node itself, we are done 137 | if apex.routing.is_hop_consumed(hop) or apex.routing.is_hop_consumed(frame['path'][hop_index-1]): 138 | break 139 | split_hop = hop.split('-') 140 | node = split_hop[0].upper() 141 | if len(split_hop) >= 2 and split_hop[1]: 142 | ssid = int(split_hop[1]) 143 | else: 144 | continue 145 | 146 | band_path = None 147 | band_path_net = None 148 | band_match = self.BAND_PATH_REGEX.match(node) 149 | if band_match is not None: 150 | band_path = band_match.group(1) 151 | band_path_net = band_match.group(2) 152 | 153 | if not band_path: 154 | continue 155 | 156 | for port_name in self.port_map.keys(): 157 | port = self.port_map[port_name] 158 | if band_path_net and node == port['net']: 159 | # only when a ssid is present should it be treated preemptively if it is a band path 160 | if not selected_hop: 161 | selected_hop['index'] = hop_index 162 | selected_hop['hop'] = hop 163 | selected_hop['node'] = node 164 | selected_hop['ssid'] = ssid 165 | selected_hop['port_name'] = port_name 166 | selected_hop['port'] = port 167 | selected_hop['band_path'] = band_path 168 | selected_hop['band_path_net'] = band_path_net 169 | elif ssid > selected_hop['ssid']: 170 | selected_hop['index'] = hop_index 171 | selected_hop['hop'] = hop 172 | selected_hop['node'] = node 173 | selected_hop['ssid'] = ssid 174 | selected_hop['port_name'] = port_name 175 | selected_hop['port'] = port 176 | selected_hop['band_path'] = band_path 177 | selected_hop['band_path_net'] = band_path_net 178 | elif not band_path_net and port['net'].startswith(band_path): 179 | # only when a ssid is present should it be treated preemptively if it is a band path 180 | if not selected_hop: 181 | selected_hop['index'] = hop_index 182 | selected_hop['hop'] = hop 183 | selected_hop['node'] = node 184 | selected_hop['ssid'] = ssid 185 | selected_hop['port_name'] = port_name 186 | selected_hop['port'] = port 187 | selected_hop['band_path'] = band_path 188 | selected_hop['band_path_net'] = band_path_net 189 | elif ssid > selected_hop['ssid']: 190 | selected_hop['index'] = hop_index 191 | selected_hop['hop'] = hop 192 | selected_hop['node'] = node 193 | selected_hop['ssid'] = ssid 194 | selected_hop['port_name'] = port_name 195 | selected_hop['port'] = port 196 | selected_hop['band_path'] = band_path 197 | selected_hop['band_path_net'] = band_path_net 198 | for hop_index in reversed(range(0, len(frame['path']))): 199 | hop = frame['path'][hop_index] 200 | # If this is the last node before a spent node, or a spent node itself, we are done 201 | if apex.routing.is_hop_consumed(hop) or apex.routing.is_hop_consumed(frame['path'][hop_index-1]): 202 | break 203 | elif selected_hop and selected_hop['index'] <= hop_index: 204 | break 205 | 206 | for port_name in self.port_map.keys(): 207 | port = self.port_map[port_name] 208 | 209 | # since the callsign specifically was specified in the path after the band-path the callsign takes 210 | # precedence 211 | if port['identifier'] == hop: 212 | selected_hop['index'] = hop_index 213 | selected_hop['hop'] = hop 214 | selected_hop['node'] = node 215 | selected_hop['ssid'] = ssid 216 | selected_hop['port_name'] = port_name 217 | selected_hop['port'] = port 218 | selected_hop['band_path'] = None 219 | selected_hop['band_path_net'] = None 220 | 221 | if not selected_hop: 222 | return 223 | 224 | # now lets digipeat this packet 225 | new_path = [] 226 | for hop_index in range(0, len(frame['path'])): 227 | hop = frame['path'][hop_index] 228 | if not apex.routing.is_hop_consumed(hop): 229 | if hop_index == selected_hop['index']: 230 | if selected_hop['band_path'] is None: 231 | new_path += [hop + '*'] 232 | else: 233 | new_path += [selected_hop['port']['identifier'] + '*'] + [hop + '*'] 234 | elif hop_index > selected_hop['index']: 235 | new_path += [hop] 236 | else: 237 | new_path += [hop] 238 | frame['path'] = new_path 239 | port['tnc'].write(frame, port['tnc_port']) 240 | self.aprsis.write(frame) 241 | return 242 | 243 | def stop(self): 244 | return 245 | 246 | def run(self): 247 | return 248 | 249 | def handle_packet(self, frame, recv_port, recv_port_name): 250 | self.__preemptive_digipeat(copy.deepcopy(frame), recv_port, recv_port_name) 251 | self.__passive_digipeat(copy.deepcopy(frame), recv_port, recv_port_name) 252 | -------------------------------------------------------------------------------- /src/apex/plugins/beacon/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # These imports are for python3 compatibility inside python2 5 | from __future__ import absolute_import 6 | from __future__ import division 7 | from __future__ import print_function 8 | 9 | import time 10 | 11 | __author__ = 'Jeffrey Phillips Freeman (WI2ARD)' 12 | __maintainer__ = 'Jeffrey Phillips Freeman (WI2ARD)' 13 | __email__ = 'jeffrey.freeman@syncleus.com' 14 | __license__ = 'Apache License, Version 2.0' 15 | __copyright__ = 'Copyright 2016, Syncleus, Inc. and contributors' 16 | __credits__ = [] 17 | __version__ = '0.0.6' 18 | 19 | plugin = None 20 | 21 | 22 | def start(config, port_map, aprsis): 23 | global plugin 24 | plugin = BeaconPlugin(config, port_map, aprsis) 25 | plugin.run() 26 | 27 | 28 | def handle_packet(frame, recv_port, recv_port_name): 29 | return 30 | 31 | 32 | def stop(): 33 | plugin.stop() 34 | 35 | 36 | class BeaconPlugin(object): 37 | 38 | def __init__(self, config, port_map, aprsis): 39 | self.port_map = port_map 40 | self.aprsis = aprsis 41 | self.running = False 42 | 43 | for section in config.sections(): 44 | if section.startswith('TNC '): 45 | tnc_name = section.split(' ')[1] 46 | for port_id in range(1, 1+int(config.get(section, 'port_count'))): 47 | port_name = tnc_name + '-' + str(port_id) 48 | port = port_map[port_name] 49 | port_section = 'PORT ' + port_name 50 | port['beacon_text'] = config.get(port_section, 'beacon_text') 51 | port['beacon_path'] = config.get(port_section, 'beacon_path') 52 | 53 | def stop(self): 54 | self.running = False 55 | 56 | def run(self): 57 | self.running = True 58 | 59 | # Don't do anything in the first 30 seconds 60 | last_trigger = time.time() 61 | while self.running and time.time() - last_trigger < 30: 62 | time.sleep(1) 63 | 64 | # run every 600 second 65 | last_trigger = time.time() 66 | while self.running: 67 | if time.time() - last_trigger >= 600: 68 | last_trigger = time.time() 69 | for port_name in self.port_map.keys(): 70 | port = self.port_map[port_name] 71 | 72 | beacon_frame = {'source': port['identifier'], 'destination': 'APRS', 73 | 'path': port['beacon_path'].split(','), 'text': port['beacon_text']} 74 | port['tnc'].write(beacon_frame, port['tnc_port']) 75 | else: 76 | time.sleep(1) 77 | -------------------------------------------------------------------------------- /src/apex/plugins/id/__init__.py: -------------------------------------------------------------------------------- 1 | ##!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # These imports are for python3 compatibility inside python2 5 | from __future__ import absolute_import 6 | from __future__ import division 7 | from __future__ import print_function 8 | 9 | import time 10 | 11 | __author__ = 'Jeffrey Phillips Freeman (WI2ARD)' 12 | __maintainer__ = 'Jeffrey Phillips Freeman (WI2ARD)' 13 | __email__ = 'jeffrey.freeman@syncleus.com' 14 | __license__ = 'Apache License, Version 2.0' 15 | __copyright__ = 'Copyright 2016, Syncleus, Inc. and contributors' 16 | __credits__ = [] 17 | __version__ = '0.0.6' 18 | 19 | plugin = None 20 | 21 | 22 | def start(config, port_map, aprsis): 23 | global plugin 24 | plugin = IdPlugin(config, port_map, aprsis) 25 | plugin.run() 26 | 27 | 28 | def handle_packet(frame, recv_port, recv_port_name): 29 | return 30 | 31 | 32 | def stop(): 33 | plugin.stop() 34 | 35 | 36 | class IdPlugin(object): 37 | 38 | def __init__(self, config, port_map, aprsis): 39 | self.port_map = port_map 40 | self.aprsis = aprsis 41 | self.running = False 42 | 43 | for section in config.sections(): 44 | if section.startswith('TNC '): 45 | tnc_name = section.split(' ')[1] 46 | for port_id in range(1, 1+int(config.get(section, 'port_count'))): 47 | port_name = tnc_name + '-' + str(port_id) 48 | port = port_map[port_name] 49 | port_section = 'PORT ' + port_name 50 | port['id_text'] = config.get(port_section, 'id_text') 51 | port['id_path'] = config.get(port_section, 'id_path') 52 | 53 | def stop(self): 54 | self.running = False 55 | 56 | def run(self): 57 | self.running = True 58 | 59 | # Don't do anything in the first 30 seconds 60 | last_trigger = time.time() 61 | while self.running and time.time() - last_trigger < 30: 62 | time.sleep(1) 63 | 64 | # run every 600 second 65 | last_trigger = time.time() 66 | while self.running: 67 | if time.time() - last_trigger >= 600: 68 | last_trigger = time.time() 69 | for port_name in self.port_map.keys(): 70 | port = self.port_map[port_name] 71 | 72 | id_frame = {'source': port['identifier'], 'destination': 'ID', 'path': port['id_path'].split(','), 73 | 'text': port['id_text']} 74 | port['tnc'].write(id_frame, port['tnc_port']) 75 | else: 76 | time.sleep(1) 77 | -------------------------------------------------------------------------------- /src/apex/plugins/status/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # These imports are for python3 compatibility inside python2 5 | from __future__ import absolute_import 6 | from __future__ import division 7 | from __future__ import print_function 8 | 9 | import time 10 | 11 | __author__ = 'Jeffrey Phillips Freeman (WI2ARD)' 12 | __maintainer__ = 'Jeffrey Phillips Freeman (WI2ARD)' 13 | __email__ = 'jeffrey.freeman@syncleus.com' 14 | __license__ = 'Apache License, Version 2.0' 15 | __copyright__ = 'Copyright 2016, Syncleus, Inc. and contributors' 16 | __credits__ = [] 17 | __version__ = '0.0.6' 18 | 19 | plugin = None 20 | 21 | 22 | def start(config, port_map, aprsis): 23 | global plugin 24 | plugin = StatusPlugin(config, port_map, aprsis) 25 | plugin.run() 26 | 27 | 28 | def handle_packet(frame, recv_port, recv_port_name): 29 | return 30 | 31 | 32 | def stop(): 33 | plugin.stop() 34 | 35 | 36 | class StatusPlugin(object): 37 | 38 | def __init__(self, config, port_map, aprsis): 39 | self.port_map = port_map 40 | self.aprsis = aprsis 41 | self.running = False 42 | 43 | for section in config.sections(): 44 | if section.startswith('TNC '): 45 | tnc_name = section.split(' ')[1] 46 | for port_id in range(1, 1+int(config.get(section, 'port_count'))): 47 | port_name = tnc_name + '-' + str(port_id) 48 | port = port_map[port_name] 49 | port_section = 'PORT ' + port_name 50 | port['status_text'] = config.get(port_section, 'status_text') 51 | port['status_path'] = config.get(port_section, 'status_path') 52 | 53 | def stop(self): 54 | self.running = False 55 | 56 | def run(self): 57 | self.running = True 58 | 59 | # Don't do anything in the first 60 seconds 60 | last_trigger = time.time() 61 | while self.running and time.time() - last_trigger < 60: 62 | time.sleep(1) 63 | 64 | # run the id every 600 seconds 65 | last_trigger = time.time() 66 | while self.running: 67 | if time.time() - last_trigger >= 600: 68 | last_trigger = time.time() 69 | for port_name in self.port_map.keys(): 70 | port = self.port_map[port_name] 71 | 72 | status_frame = { 73 | 'source': port['identifier'], 74 | 'destination': 'APRS', 75 | 'path': port['status_path'].split(','), 76 | 'text': port['status_text']} 77 | port['tnc'].write(status_frame, port['tnc_port']) 78 | else: 79 | time.sleep(1) 80 | -------------------------------------------------------------------------------- /src/apex/routing/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # These imports are for python3 compatibility inside python2 5 | from __future__ import absolute_import 6 | from __future__ import division 7 | from __future__ import print_function 8 | 9 | from .route import has_seen # noqa: F401 10 | from .route import is_hop_consumed # noqa: F401 11 | 12 | __author__ = 'Jeffrey Phillips Freeman (WI2ARD)' 13 | __maintainer__ = 'Jeffrey Phillips Freeman (WI2ARD)' 14 | __email__ = 'jeffrey.freeman@syncleus.com' 15 | __license__ = 'Apache License, Version 2.0' 16 | __copyright__ = 'Copyright 2016, Syncleus, Inc. and contributors' 17 | __credits__ = [] 18 | __version__ = '0.0.6' 19 | -------------------------------------------------------------------------------- /src/apex/routing/route.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # These imports are for python3 compatibility inside python2 5 | from __future__ import absolute_import 6 | from __future__ import division 7 | from __future__ import print_function 8 | 9 | __author__ = 'Jeffrey Phillips Freeman (WI2ARD)' 10 | __maintainer__ = 'Jeffrey Phillips Freeman (WI2ARD)' 11 | __email__ = 'jeffrey.freeman@syncleus.com' 12 | __license__ = 'Apache License, Version 2.0' 13 | __copyright__ = 'Copyright 2016, Syncleus, Inc. and contributors' 14 | __credits__ = [] 15 | 16 | 17 | def has_seen(port_map, frame): 18 | # Can't digipeat anything when you are the source 19 | for port in port_map.values(): 20 | if frame['source'] == port['identifier']: 21 | return True 22 | 23 | # can't digipeat things we already digipeated. 24 | for hop in frame['path']: 25 | for port in port_map.values(): 26 | if hop.startswith(port['identifier']) and hop.endswith('*'): 27 | return True 28 | 29 | return False 30 | 31 | 32 | def is_hop_consumed(hop): 33 | if hop.strip()[-1] is '*': 34 | return True 35 | else: 36 | return False 37 | -------------------------------------------------------------------------------- /src/apex/util.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # These imports are for python3 compatibility inside python2 5 | from __future__ import absolute_import 6 | from __future__ import division 7 | from __future__ import print_function 8 | 9 | import click 10 | 11 | 12 | def echo_colorized_frame(frame, port_name, direction_in): 13 | formatted_aprs = '>'.join([click.style(frame['source'], fg='green'), click.style(frame['destination'], fg='blue')]) 14 | paths = [] 15 | for path in frame['path']: 16 | paths.append(click.style(path, fg='cyan')) 17 | paths = ','.join(paths) 18 | if frame['path']: 19 | formatted_aprs = ','.join([formatted_aprs, paths]) 20 | formatted_aprs += ':' 21 | formatted_aprs += frame['text'] 22 | if direction_in: 23 | click.echo(click.style(port_name + ' << ', fg='magenta') + formatted_aprs) 24 | else: 25 | click.echo(click.style(port_name + ' >> ', fg='magenta', bold=True, blink=True) + formatted_aprs) 26 | 27 | 28 | def echo_colorized_error(text): 29 | click.echo(click.style('Error: ', fg='red', bold=True, blink=True) + click.style(text, bold=True)) 30 | 31 | 32 | def echo_colorized_warning(text): 33 | click.echo(click.style('Warning: ', fg='yellow') + click.style(text)) 34 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # These imports are for python3 compatibility inside python2 5 | from __future__ import absolute_import 6 | from __future__ import division 7 | from __future__ import print_function 8 | 9 | from .kiss_mock import KissMock # noqa: F401 10 | 11 | __author__ = 'Jeffrey Phillips Freeman (WI2ARD)' 12 | __maintainer__ = 'Jeffrey Phillips Freeman (WI2ARD)' 13 | __email__ = 'jeffrey.freeman@syncleus.com' 14 | __license__ = 'Apache License, Version 2.0' 15 | __copyright__ = 'Copyright 2016, Syncleus, Inc. and contributors' 16 | __credits__ = [] 17 | -------------------------------------------------------------------------------- /tests/constants.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Constants for APEX Module Tests.""" 5 | 6 | # These imports are for python3 compatibility inside python2 7 | from __future__ import absolute_import 8 | from __future__ import division 9 | from __future__ import print_function 10 | 11 | __author__ = 'Jeffrey Phillips Freeman (WI2ARD)' 12 | __maintainer__ = 'Jeffrey Phillips Freeman (WI2ARD)' 13 | __email__ = 'jeffrey.freeman@syncleus.com' 14 | __license__ = 'Apache License, Version 2.0' 15 | __copyright__ = 'Copyright 2016, Syncleus, Inc. and contributors' 16 | __credits__ = [] 17 | 18 | 19 | TEST_FRAMES = 'tests/test_frames.log' 20 | -------------------------------------------------------------------------------- /tests/context.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Context for tests for APRS Python Module.""" 5 | 6 | # These imports are for python3 compatibility inside python2 7 | from __future__ import absolute_import 8 | from __future__ import division 9 | from __future__ import print_function 10 | 11 | import os 12 | import sys 13 | 14 | __author__ = 'Jeffrey Phillips Freeman (WI2ARD)' 15 | __maintainer__ = 'Jeffrey Phillips Freeman (WI2ARD)' 16 | __email__ = 'jeffrey.freeman@syncleus.com' 17 | __license__ = 'Apache License, Version 2.0' 18 | __copyright__ = 'Copyright 2016, Syncleus, Inc. and contributors' 19 | __credits__ = [] 20 | 21 | sys.path.insert(0, os.path.abspath('..')) 22 | -------------------------------------------------------------------------------- /tests/kiss_mock.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # These imports are for python3 compatibility inside python2 5 | from __future__ import absolute_import 6 | from __future__ import division 7 | from __future__ import print_function 8 | 9 | import apex.kiss.constants 10 | 11 | __author__ = 'Jeffrey Phillips Freeman (WI2ARD)' 12 | __maintainer__ = 'Jeffrey Phillips Freeman (WI2ARD)' 13 | __email__ = 'jeffrey.freeman@syncleus.com' 14 | __license__ = 'Apache License, Version 2.0' 15 | __copyright__ = 'Copyright 2016, Syncleus, Inc. and contributors' 16 | __credits__ = [] 17 | 18 | 19 | class KissMock(apex.kiss.Kiss): 20 | 21 | def __init__(self, 22 | strip_df_start=True): 23 | super(KissMock, self).__init__(strip_df_start) 24 | self.read_from_interface = [] 25 | self.sent_to_interface = [] 26 | 27 | def _read_interface(self): 28 | if not len(self.read_from_interface): 29 | return None 30 | raw_frame = self.read_from_interface[0] 31 | del self.read_from_interface[0] 32 | return raw_frame 33 | 34 | def _write_interface(self, data): 35 | self.sent_to_interface.append(data) 36 | 37 | def clear_interface(self): 38 | self.read_from_interface = [] 39 | self.sent_to_interface = [] 40 | 41 | def add_read_from_interface(self, raw_frame): 42 | self.read_from_interface.append(raw_frame) 43 | 44 | def get_sent_to_interface(self): 45 | return self.sent_to_interface 46 | -------------------------------------------------------------------------------- /tests/test_apex.py: -------------------------------------------------------------------------------- 1 | # These imports are for python3 compatibility inside python2 2 | from __future__ import absolute_import 3 | from __future__ import division 4 | from __future__ import print_function 5 | 6 | __author__ = 'Jeffrey Phillips Freeman (WI2ARD)' 7 | __maintainer__ = 'Jeffrey Phillips Freeman (WI2ARD)' 8 | __email__ = 'jeffrey.freeman@syncleus.com' 9 | __license__ = 'Apache License, Version 2.0' 10 | __copyright__ = 'Copyright 2016, Syncleus, Inc. and contributors' 11 | __credits__ = [] 12 | 13 | # from click.testing import CliRunner 14 | # from apex.cli import main 15 | # 16 | # def test_main(): 17 | # runner = CliRunner() 18 | # result = runner.invoke(main, []) 19 | # 20 | # assert result.output == '()\n' 21 | # assert result.exit_code == 0 22 | -------------------------------------------------------------------------------- /tests/test_aprs.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Tests for Python APRS-IS Bindings.""" 5 | 6 | # These imports are for python3 compatibility inside python2 7 | from __future__ import absolute_import 8 | from __future__ import division 9 | from __future__ import print_function 10 | 11 | import unittest 12 | 13 | import six 14 | 15 | import apex.aprs.constants 16 | import apex.aprs.igate 17 | 18 | from .kiss_mock import KissMock 19 | 20 | if six.PY2: 21 | import httpretty 22 | 23 | __author__ = 'Jeffrey Phillips Freeman (WI2ARD)' 24 | __maintainer__ = 'Jeffrey Phillips Freeman (WI2ARD)' 25 | __email__ = 'jeffrey.freeman@syncleus.com' 26 | __license__ = 'Apache License, Version 2.0' 27 | __copyright__ = 'Copyright 2016, Syncleus, Inc. and contributors' 28 | __credits__ = [] 29 | __version__ = '0.0.2' 30 | 31 | ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' 32 | NUMBERS = '0123456789' 33 | POSITIVE_NUMBERS = NUMBERS[1:] 34 | ALPHANUM = ''.join([ALPHABET, NUMBERS]) 35 | 36 | DECODED_CALLSIGN = callsign = {'callsign': 'W2GMD', 'ssid': 1} 37 | ENCODED_CALLSIGN = [174, 100, 142, 154, 136, 64, 98] 38 | 39 | DECODED_CALLSIGN_DIGIPEATED = {'callsign': 'W2GMD*', 'ssid': 1} 40 | ENCODED_CALLSIGN_DIGIPEATED = [174, 100, 142, 154, 136, 64, 226] 41 | 42 | DECODED_FRAME = { 43 | 'source': 'W2GMD-1', 44 | 'destination': 'OMG', 45 | 'path': ['WIDE1-1'], 46 | 'text': 'test_encode_frame' 47 | } 48 | ENCODED_FRAME = [158, 154, 142, 64, 64, 64, 96, 174, 100, 142, 154, 136, 64, 98, 174, 146, 136, 138, 98, 64, 99, 3, 240, 49 | 116, 101, 115, 116, 95, 101, 110, 99, 111, 100, 101, 95, 102, 114, 97, 109, 101] 50 | 51 | DECODED_FRAME_RECORDED = { 52 | 'source': 'W2GMD-6', 53 | 'destination': 'APRX24', 54 | 'path': ['WIDE1-1'], 55 | 'text': ('!3745.75NI12228.05W#W2GMD-6 Inner Sunset, ' 56 | 'SF iGate/Digipeater http://w2gmd.org') 57 | } 58 | ENCODED_FRAME_RECORDED = [130, 160, 164, 176, 100, 104, 96, 174, 100, 142, 154, 136, 64, 108, 174, 146, 136, 138, 98, 59 | 64, 99, 3, 240, 33, 51, 55, 52, 53, 46, 55, 53, 78, 73, 49, 50, 50, 50, 56, 46, 48, 53, 87, 60 | 35, 87, 50, 71, 77, 68, 45, 54, 32, 73, 110, 110, 101, 114, 32, 83, 117, 110, 115, 101, 116, 61 | 44, 32, 83, 70, 32, 105, 71, 97, 116, 101, 47, 68, 105, 103, 105, 112, 101, 97, 116, 101, 114, 62 | 32, 104, 116, 116, 112, 58, 47, 47, 119, 50, 103, 109, 100, 46, 111, 114, 103] 63 | 64 | DECODED_FRAME_MULTIPATH = { 65 | 'source': 'W2GMD-1', 66 | 'destination': 'OMG', 67 | 'path': ['WIDE1-1', 'WIDE2-2'], 68 | 'text': 'test_encode_frame' 69 | } 70 | ENCODED_FRAME_MULTIPATH = [158, 154, 142, 64, 64, 64, 96, 174, 100, 142, 154, 136, 64, 98, 174, 146, 136, 138, 98, 64, 71 | 98, 174, 146, 136, 138, 100, 64, 101, 3, 240, 116, 101, 115, 116, 95, 101, 110, 99, 111, 100, 72 | 101, 95, 102, 114, 97, 109, 101] 73 | 74 | DECODED_FRAME_KISS = { 75 | 'source': 'W2GMD-1', 76 | 'destination': 'OMG', 77 | 'path': ['WIDE1-1', 'WIDE2-2'], 78 | 'text': 'test_encode_frame' 79 | } 80 | ENCODED_FRAME_KISS = [192, 0, 158, 154, 142, 64, 64, 64, 96, 174, 100, 142, 154, 136, 64, 98, 174, 146, 136, 138, 98, 81 | 64, 98, 174, 146, 136, 138, 100, 64, 101, 3, 240, 116, 101, 115, 116, 95, 101, 110, 99, 111, 100, 82 | 101, 95, 102, 114, 97, 109, 101, 192] 83 | 84 | DECODED_FRAME_KISS_INVALID = { 85 | 'source': 'KG6WTF', 86 | 'destination': 'S7TSUV', 87 | 'path': ['MTOSO-2', 'WIDE2*' 'qAR', 'KF6FIR-10'], 88 | 'text': '`17El#X-/kg6wtf@gosselinfamily.com' 89 | } 90 | ENCODED_FRAME_KISS_INVALID = [192, 0, 166, 110, 168, 166, 170, 172, 96, 150, 142, 108, 174, 168, 140, 96, 154, 168, 158, 91 | 166, 158, 64, 100, 174, 146, 136, 138, 100, 226, 130, 164, 224, 150, 140, 108, 140, 146, 164, 92 | 117, 3, 240, 96, 49, 55, 69, 108, 35, 88, 45, 47, 107, 103, 54, 119, 116, 102, 64, 103, 111, 93 | 115, 115, 101, 108, 105, 110, 102, 97, 109, 105, 108, 121, 46, 99, 111, 109, 192] 94 | 95 | 96 | 97 | class AprsTest(unittest.TestCase): # pylint: disable=R0904 98 | """Tests for Python APRS-IS Bindings.""" 99 | 100 | def setUp(self): # pylint: disable=C0103 101 | self.fake_server = 'http://localhost:5567/' 102 | self.fake_callsign = 'KWN4YGH-5' 103 | 104 | def test_read_frame_kiss(self): 105 | kiss_mock = KissMock() 106 | aprs_kiss = apex.aprs.AprsKiss(kiss_mock) 107 | 108 | kiss_mock.clear_interface() 109 | kiss_mock.add_read_from_interface(ENCODED_FRAME_KISS) 110 | translated_frame = None 111 | iter_left = 1000 112 | while not translated_frame and iter_left > 0: 113 | translated_frame = aprs_kiss.read() 114 | iter_left -= 1 115 | 116 | self.assertEqual(DECODED_FRAME_KISS, translated_frame) 117 | 118 | def test_write_frame_kiss(self): 119 | kiss_mock = KissMock() 120 | aprs_kiss = apex.aprs.AprsKiss(kiss_mock) 121 | 122 | kiss_mock.clear_interface() 123 | aprs_kiss.write(DECODED_FRAME_KISS) 124 | all_raw_frames = kiss_mock.get_sent_to_interface() 125 | 126 | self.assertEqual(ENCODED_FRAME_KISS, all_raw_frames[0]) 127 | 128 | def test_read_frame_kiss_invalid(self): 129 | kiss_mock = KissMock() 130 | aprs_kiss = apex.aprs.AprsKiss(kiss_mock) 131 | 132 | kiss_mock.clear_interface() 133 | kiss_mock.add_read_from_interface(ENCODED_FRAME_KISS_INVALID) 134 | translated_frame = None 135 | iter_left = 1000 136 | while not translated_frame and iter_left > 0: 137 | translated_frame = aprs_kiss.read() 138 | iter_left -= 1 139 | 140 | self.assertEqual(None, translated_frame) 141 | 142 | def test_write_frame_kiss_invalid(self): 143 | kiss_mock = KissMock() 144 | aprs_kiss = apex.aprs.AprsKiss(kiss_mock) 145 | 146 | kiss_mock.clear_interface() 147 | aprs_kiss.write(DECODED_FRAME_KISS_INVALID) 148 | all_raw_frames = kiss_mock.get_sent_to_interface() 149 | 150 | self.assertEqual(0, len(all_raw_frames)) 151 | 152 | def test_encode_callsign(self): 153 | """ 154 | Tests encoding a callsign. 155 | """ 156 | encoded_callsign = apex.aprs.AprsKiss._AprsKiss__encode_callsign(DECODED_CALLSIGN) 157 | self.assertEqual(ENCODED_CALLSIGN, encoded_callsign) 158 | 159 | def test_encode_callsign_digipeated(self): 160 | """ 161 | Tests encoding a digipeated callsign with 162 | `aprs.util.encode_callsign()`. 163 | """ 164 | encoded_callsign = apex.aprs.AprsKiss._AprsKiss__encode_callsign(DECODED_CALLSIGN_DIGIPEATED) 165 | self.assertEqual(ENCODED_CALLSIGN_DIGIPEATED, encoded_callsign) 166 | 167 | def test_decode_callsign(self): 168 | """ 169 | Tests extracting the callsign from a KISS-encoded APRS frame. 170 | """ 171 | decoded_callsign = apex.aprs.AprsKiss._AprsKiss__extract_callsign(ENCODED_CALLSIGN) 172 | self.assertEqual(DECODED_CALLSIGN, decoded_callsign) 173 | 174 | def test_decode_callsign_digipeated(self): 175 | """ 176 | Tests extracting the callsign from a KISS-encoded APRS frame. 177 | """ 178 | decoded_callsign = apex.aprs.AprsKiss._AprsKiss__extract_callsign(ENCODED_CALLSIGN_DIGIPEATED) 179 | self.assertEqual(DECODED_CALLSIGN, decoded_callsign) 180 | 181 | def test_encode_frame(self): 182 | """ 183 | Tests KISS-encoding an APRS. 184 | """ 185 | encoded_frame = apex.aprs.AprsKiss._AprsKiss__encode_frame(DECODED_FRAME) 186 | self.assertEqual(ENCODED_FRAME, encoded_frame) 187 | 188 | def test_encode_frame_recorded(self): 189 | """ 190 | Tests encoding a KISS-encoded APRS. 191 | """ 192 | encoded_frame = apex.aprs.AprsKiss._AprsKiss__encode_frame(DECODED_FRAME_RECORDED) 193 | self.assertEqual(ENCODED_FRAME_RECORDED, encoded_frame) 194 | 195 | def test_encode_frame_multipath(self): 196 | """ 197 | Tests encoding a KISS-encoded APRS. 198 | """ 199 | encoded_frame = apex.aprs.AprsKiss._AprsKiss__encode_frame(DECODED_FRAME_MULTIPATH) 200 | self.assertEqual(ENCODED_FRAME_MULTIPATH, encoded_frame) 201 | 202 | def test_decode_frame(self): 203 | """ 204 | Tests KISS-encoding an APRS 205 | """ 206 | decoded_frame = apex.aprs.AprsKiss._AprsKiss__decode_frame(ENCODED_FRAME) 207 | self.assertEqual(DECODED_FRAME, decoded_frame) 208 | 209 | def test_decode_frame_recorded(self): 210 | """ 211 | Tests decoding a KISS-encoded APRS frame 212 | """ 213 | decoded_frame = apex.aprs.AprsKiss._AprsKiss__decode_frame(ENCODED_FRAME_RECORDED) 214 | self.assertEqual(DECODED_FRAME_RECORDED, decoded_frame) 215 | 216 | def test_decode_frame_multipath(self): 217 | """ 218 | Tests decoding a KISS-encoded APRS frame 219 | """ 220 | decoded_frame = apex.aprs.AprsKiss._AprsKiss__decode_frame(ENCODED_FRAME_MULTIPATH) 221 | self.assertEqual(DECODED_FRAME_MULTIPATH, decoded_frame) 222 | 223 | def test_extract_path(self): 224 | """ 225 | Tests extracting the APRS path from a KISS-encoded. 226 | """ 227 | extracted_path = apex.aprs.AprsKiss._AprsKiss__extract_path(3, ENCODED_FRAME) 228 | self.assertEqual(DECODED_FRAME['path'][0], extracted_path[0]) 229 | 230 | def test_idwentity_as_string_with_ssid(self): 231 | """ 232 | Tests creating a full callsign string from a callsign+ssid dict using 233 | `aprs.util.full_callsign()`. 234 | """ 235 | callsign = { 236 | 'callsign': 'W2GMD', 237 | 'ssid': 1 238 | } 239 | full_callsign = apex.aprs.AprsKiss._AprsKiss__identity_as_string(callsign) 240 | self.assertEqual(full_callsign, 'W2GMD-1') 241 | 242 | def test_identity_as_string_sans_ssid(self): 243 | """ 244 | Tests creating a full callsign string from a callsign dict using 245 | `aprs.util.full_callsign()`. 246 | """ 247 | callsign = { 248 | 'callsign': 'W2GMD', 249 | 'ssid': 0 250 | } 251 | full_callsign = apex.aprs.AprsKiss._AprsKiss__identity_as_string(callsign) 252 | self.assertEqual(full_callsign, 'W2GMD') 253 | 254 | if six.PY2: 255 | @httpretty.httprettified 256 | def test_fake_good_auth(self): 257 | """ 258 | Tests authenticating against APRS-IS using a valid call+pass. 259 | """ 260 | httpretty.HTTPretty.register_uri( 261 | httpretty.HTTPretty.POST, 262 | self.fake_server, 263 | status=204 264 | ) 265 | 266 | aprs_conn = apex.aprs.igate.IGate( 267 | user=self.fake_callsign, 268 | input_url=self.fake_server 269 | ) 270 | aprs_conn.connect() 271 | 272 | msg = { 273 | 'source': self.fake_callsign, 274 | 'destination': 'APRS', 275 | 'path': ['TCPIP*'], 276 | 'text': '=3745.00N/12227.00W-Simulated Location' 277 | } 278 | 279 | result = aprs_conn.write(msg) 280 | 281 | self.assertTrue(result) 282 | 283 | @httpretty.httprettified 284 | def test_fake_bad_auth_http(self): 285 | """ 286 | Tests authenticating against APRS-IS using an invalid call+pass. 287 | """ 288 | httpretty.HTTPretty.register_uri( 289 | httpretty.HTTPretty.POST, 290 | self.fake_server, 291 | status=401 292 | ) 293 | 294 | aprs_conn = apex.aprs.igate.IGate( 295 | user=self.fake_callsign, 296 | input_url=self.fake_server 297 | ) 298 | aprs_conn.connect() 299 | 300 | msg = { 301 | 'source': self.fake_callsign, 302 | 'destination': 'APRS', 303 | 'path': ['TCPIP*'], 304 | 'text': '=3745.00N/12227.00W-Simulated Location' 305 | } 306 | 307 | result = aprs_conn.write(msg, protocol='HTTP') 308 | 309 | self.assertFalse(result) 310 | 311 | @unittest.skip('Test only works with real server.') 312 | def test_more(self): 313 | """ 314 | Tests APRS-IS binding against a real APRS-IS server. 315 | """ 316 | aprs_conn = apex.aprs.igate.IGate( 317 | user=self.real_callsign, 318 | input_url=self.real_server 319 | ) 320 | aprs_conn.connect() 321 | 322 | msg = { 323 | 'source': self.fake_callsign, 324 | 'destination': 'APRS', 325 | 'path': ['TCPIP*'], 326 | 'text': '=3745.00N/12227.00W-Simulated Location' 327 | } 328 | self.logger.debug(locals()) 329 | 330 | result = aprs_conn.write(msg) 331 | 332 | self.assertFalse(result) 333 | -------------------------------------------------------------------------------- /tests/test_kiss.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Tests for KISS Util Module.""" 5 | 6 | # These imports are for python3 compatibility inside python2 7 | from __future__ import absolute_import 8 | from __future__ import division 9 | from __future__ import print_function 10 | 11 | import unittest 12 | 13 | import apex.kiss 14 | import apex.kiss.constants 15 | 16 | from .kiss_mock import KissMock 17 | 18 | __author__ = 'Jeffrey Phillips Freeman (WI2ARD)' 19 | __maintainer__ = 'Jeffrey Phillips Freeman (WI2ARD)' 20 | __email__ = 'jeffrey.freeman@syncleus.com' 21 | __license__ = 'Apache License, Version 2.0' 22 | __copyright__ = 'Copyright 2016, Syncleus, Inc. and contributors' 23 | __credits__ = [] 24 | 25 | # KG6WTF>S7TSUV,MTOSO-2,WIDE2*,qAR,KF6FIR-10:`17El#X-/kg6wtf@gosselinfamily.com 26 | ENCODED_FRAME = [192, 0, 75, 71, 54, 87, 84, 70, 62, 83, 55, 84, 83, 85, 86, 44, 77, 84, 79, 83, 79, 45, 50, 44, 87, 73, 27 | 68, 69, 50, 42, 44, 113, 65, 82, 44, 75, 70, 54, 70, 73, 82, 45, 49, 48, 58, 96, 49, 55, 69, 108, 35, 28 | 88, 45, 47, 107, 103, 54, 119, 116, 102, 64, 103, 111, 115, 115, 101, 108, 105, 110, 102, 97, 109, 105, 29 | 108, 121, 46, 99, 111, 109, 192] 30 | DECODED_FRAME = [75, 71, 54, 87, 84, 70, 62, 83, 55, 84, 83, 85, 86, 44, 77, 84, 79, 83, 79, 45, 50, 44, 87, 73, 68, 31 | 69, 50, 42, 44, 113, 65, 82, 44, 75, 70, 54, 70, 73, 82, 45, 49, 48, 58, 96, 49, 55, 69, 108, 35, 32 | 88, 45, 47, 107, 103, 54, 119, 116, 102, 64, 103, 111, 115, 115, 101, 108, 105, 110, 102, 97, 109, 33 | 105, 108, 121, 46, 99, 111, 109] 34 | 35 | # pylint: disable=R0904,C0103 36 | class KissUtilTestCase(unittest.TestCase): 37 | 38 | """Test class for KISS Python Module.""" 39 | 40 | def setUp(self): 41 | """Setup.""" 42 | self.kiss_mock = KissMock() 43 | 44 | def tearDown(self): 45 | """Teardown.""" 46 | self.kiss_mock.clear_interface() 47 | 48 | def test_escape_special_codes_fend(self): 49 | """ 50 | Tests `kiss.util.escape_special_codes` util function. 51 | """ 52 | # fend = apex.kiss.util.escape_special_codes(apex.kiss.constants.FEND) 53 | fend = apex.kiss.Kiss._Kiss__escape_special_codes([apex.kiss.constants.FEND]) # pylint: disable=E1101 54 | self.assertEqual(fend, apex.kiss.constants.FESC_TFEND) 55 | 56 | def test_escape_special_codes_fesc(self): 57 | """ 58 | Tests `kiss.util.escape_special_codes` util function. 59 | """ 60 | fesc = apex.kiss.Kiss._Kiss__escape_special_codes([apex.kiss.constants.FESC]) # pylint: disable=E1101 61 | self.assertEqual(fesc, apex.kiss.constants.FESC_TFESC) 62 | 63 | def test_read(self): 64 | self.kiss_mock.clear_interface() 65 | self.kiss_mock.add_read_from_interface(ENCODED_FRAME) 66 | translated_frame = self.kiss_mock.read() 67 | self.assertEqual(DECODED_FRAME, translated_frame) 68 | 69 | def test_write(self): 70 | self.kiss_mock.clear_interface() 71 | self.kiss_mock.write(DECODED_FRAME) 72 | all_raw_frames = self.kiss_mock.get_sent_to_interface() 73 | self.assertEqual(ENCODED_FRAME, all_raw_frames[0]) 74 | 75 | 76 | if __name__ == '__main__': 77 | unittest.main() 78 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Tests for Python APRS util methods.""" 5 | 6 | # These imports are for python3 compatibility inside python2 7 | from __future__ import absolute_import 8 | from __future__ import division 9 | from __future__ import print_function 10 | 11 | import logging 12 | import logging.handlers 13 | import unittest 14 | 15 | import apex.aprs.util 16 | from apex.aprs import constants as aprs_constants 17 | 18 | __author__ = 'Jeffrey Phillips Freeman (WI2ARD)' 19 | __maintainer__ = 'Jeffrey Phillips Freeman (WI2ARD)' 20 | __email__ = 'jeffrey.freeman@syncleus.com' 21 | __license__ = 'Apache License, Version 2.0' 22 | __copyright__ = 'Copyright 2016, Syncleus, Inc. and contributors' 23 | __credits__ = [] 24 | 25 | ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' 26 | NUMBERS = '0123456789' 27 | POSITIVE_NUMBERS = NUMBERS[1:] 28 | ALPHANUM = ''.join([ALPHABET, NUMBERS]) 29 | 30 | VALID_CALLSIGNS = ['W2GMD', 'W2GMD-1', 'KF4MKT', 'KF4MKT-1', 'KF4LZA-15'] 31 | INVALID_CALLSIGNS = ['xW2GMDx', 'W2GMD-16', 'W2GMD-A', 'W', 'W2GMD-1-0', 32 | 'W*GMD', 'W2GMD-123'] 33 | 34 | 35 | class AprsUtilTestCase(unittest.TestCase): # pylint: disable=R0904 36 | """Tests for Python APRS Utils.""" 37 | 38 | logger = logging.getLogger(__name__) 39 | logger.setLevel(aprs_constants.LOG_LEVEL) 40 | console_handler = logging.StreamHandler() 41 | console_handler.setLevel(aprs_constants.LOG_LEVEL) 42 | formatter = logging.Formatter(aprs_constants.LOG_FORMAT) 43 | console_handler.setFormatter(formatter) 44 | logger.addHandler(console_handler) 45 | logger.propagate = False 46 | 47 | def test_latitude_north(self): 48 | """Test Decimal to APRS Latitude conversion. 49 | 50 | Spec per ftp://ftp.tapr.org/aprssig/aprsspec/spec/aprs101/APRS101.pdf 51 | -- 52 | Latitude is expressed as a fixed 8-character field, in degrees and 53 | decimal minutes (to two decimal places), followed by the letter N for 54 | north or S for south. Latitude degrees are in the range 00 to 90. 55 | Latitude minutes are expressed as whole minutes and hundredths of a 56 | minute, separated by a decimal point. 57 | 58 | For example: 59 | 60 | 4903.50N is 49 degrees 3 minutes 30 seconds north. 61 | 62 | In generic format examples, the latitude is shown as the 8-character 63 | string ddmm.hhN (i.e. degrees, minutes and hundredths of a minute 64 | north). 65 | """ 66 | test_lat = 37.7418096 67 | aprs_lat = apex.aprs.util.dec2dm_lat(test_lat) 68 | self.logger.debug('aprs_lat=%s', aprs_lat) 69 | 70 | lat_deg = int(aprs_lat.split('.')[0][:1]) 71 | # lat_hsec = aprs_lat.split('.')[1] 72 | 73 | self.assertTrue(len(aprs_lat) == 8) 74 | self.assertTrue(lat_deg >= 00) 75 | self.assertTrue(lat_deg <= 90) 76 | self.assertTrue(aprs_lat.endswith('N')) 77 | 78 | def test_latitude_south(self): 79 | """Test Decimal to APRS Latitude conversion. 80 | 81 | Spec per ftp://ftp.tapr.org/aprssig/aprsspec/spec/aprs101/APRS101.pdf 82 | -- 83 | Latitude is expressed as a fixed 8-character field, in degrees and 84 | decimal minutes (to two decimal places), followed by the letter N for 85 | north or S for south. Latitude degrees are in the range 00 to 90. 86 | Latitude minutes are expressed as whole minutes and hundredths of a 87 | minute, separated by a decimal point. 88 | 89 | For example: 90 | 91 | 4903.50N is 49 degrees 3 minutes 30 seconds north. 92 | 93 | In generic format examples, the latitude is shown as the 8-character 94 | string ddmm.hhN (i.e. degrees, minutes and hundredths of a minute 95 | north). 96 | """ 97 | test_lat = -37.7418096 98 | aprs_lat = apex.aprs.util.dec2dm_lat(test_lat) 99 | self.logger.debug('aprs_lat=%s', aprs_lat) 100 | 101 | lat_deg = int(aprs_lat.split('.')[0][:1]) 102 | # lat_hsec = aprs_lat.split('.')[1] 103 | 104 | self.assertTrue(len(aprs_lat) == 8) 105 | self.assertTrue(lat_deg >= 00) 106 | self.assertTrue(lat_deg <= 90) 107 | self.assertTrue(aprs_lat.endswith('S')) 108 | 109 | def test_longitude_west(self): 110 | """Test Decimal to APRS Longitude conversion. 111 | 112 | Spec per ftp://ftp.tapr.org/aprssig/aprsspec/spec/aprs101/APRS101.pdf 113 | -- 114 | Longitude is expressed as a fixed 9-character field, in degrees and 115 | decimal minutes (to two decimal places), followed by the letter E for 116 | east or W for west. 117 | 118 | Longitude degrees are in the range 000 to 180. Longitude minutes are 119 | expressed as whole minutes and hundredths of a minute, separated by a 120 | decimal point. 121 | 122 | For example: 123 | 124 | 07201.75W is 72 degrees 1 minute 45 seconds west. 125 | 126 | In generic format examples, the longitude is shown as the 9-character 127 | string dddmm.hhW (i.e. degrees, minutes and hundredths of a minute 128 | west). 129 | """ 130 | test_lng = -122.38833 131 | aprs_lng = apex.aprs.util.dec2dm_lng(test_lng) 132 | self.logger.debug('aprs_lng=%s', aprs_lng) 133 | 134 | lng_deg = int(aprs_lng.split('.')[0][:2]) 135 | # lng_hsec = aprs_lng.split('.')[1] 136 | 137 | self.assertTrue(len(aprs_lng) == 9) 138 | self.assertTrue(lng_deg >= 000) 139 | self.assertTrue(lng_deg <= 180) 140 | self.assertTrue(aprs_lng.endswith('W')) 141 | 142 | def test_longitude_east(self): 143 | """Test Decimal to APRS Longitude conversion. 144 | 145 | Spec per ftp://ftp.tapr.org/aprssig/aprsspec/spec/aprs101/APRS101.pdf 146 | -- 147 | Longitude is expressed as a fixed 9-character field, in degrees and 148 | decimal minutes (to two decimal places), followed by the letter E for 149 | east or W for west. 150 | 151 | Longitude degrees are in the range 000 to 180. Longitude minutes are 152 | expressed as whole minutes and hundredths of a minute, separated by a 153 | decimal point. 154 | 155 | For example: 156 | 157 | 07201.75W is 72 degrees 1 minute 45 seconds west. 158 | 159 | In generic format examples, the longitude is shown as the 9-character 160 | string dddmm.hhW (i.e. degrees, minutes and hundredths of a minute 161 | west). 162 | """ 163 | test_lng = 122.38833 164 | aprs_lng = apex.aprs.util.dec2dm_lng(test_lng) 165 | self.logger.debug('aprs_lng=%s', aprs_lng) 166 | 167 | lng_deg = int(aprs_lng.split('.')[0][:2]) 168 | # lng_hsec = aprs_lng.split('.')[1] 169 | 170 | self.assertTrue(len(aprs_lng) == 9) 171 | self.assertTrue(lng_deg >= 000) 172 | self.assertTrue(lng_deg <= 180) 173 | self.assertTrue(aprs_lng.endswith('E')) 174 | 175 | def test_valid_callsign_valid(self): 176 | """ 177 | Tests valid callsigns using `aprs.util.valid_callsign()`. 178 | """ 179 | for i in VALID_CALLSIGNS: 180 | self.assertTrue(apex.aprs.util.valid_callsign(i), '%s is a valid call' % i) 181 | 182 | def test_valid_callsign_invalid(self): 183 | """ 184 | Tests invalid callsigns using `aprs.util.valid_callsign()`. 185 | """ 186 | for i in INVALID_CALLSIGNS: 187 | self.assertFalse( 188 | apex.aprs.util.valid_callsign(i), '%s is an invalid call' % i) 189 | 190 | def test_decode_aprs_ascii_frame(self): 191 | """ 192 | Tests creating an APRS frame-as-dict from an APRS frame-as-string 193 | using `aprs.util.decode_aprs_ascii_frame()`. 194 | """ 195 | ascii_frame = ( 196 | 'W2GMD-9>APOTC1,WIDE1-1,WIDE2-1:!3745.94N/12228.05W>118/010/' 197 | 'A=000269 38C=Temp http://w2gmd.org/ Twitter: @ampledata') 198 | frame = apex.aprs.util.decode_frame(ascii_frame) 199 | self.assertEqual( 200 | { 201 | 'source': 'W2GMD-9', 202 | 'destination': 'APOTC1', 203 | 'path': 'APOTC1,WIDE1-1,WIDE2-1', 204 | 'text': ('!3745.94N/12228.05W>118/010/A=000269 38C=Temp ' 205 | 'http://w2gmd.org/ Twitter: @ampledata'), 206 | }, 207 | frame 208 | ) 209 | 210 | def test_format_aprs_frame(self): 211 | """ 212 | Tests formatting an APRS frame-as-string from an APRS frame-as-dict 213 | using `aprs.util.format_aprs_frame()`. 214 | """ 215 | frame = { 216 | 'source': 'W2GMD-1', 217 | 'destination': 'OMG', 218 | 'path': ['WIDE1-1'], 219 | 'text': 'test_format_aprs_frame' 220 | } 221 | formatted_frame = apex.aprs.util.encode_frame(frame) 222 | self.assertEqual( 223 | formatted_frame, 224 | 'W2GMD-1>OMG,WIDE1-1:test_format_aprs_frame' 225 | ) 226 | 227 | 228 | if __name__ == '__main__': 229 | unittest.main() 230 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | clean, 4 | check, 5 | 2.7-cover, 6 | 2.7-nocov, 7 | 3.3-cover, 8 | 3.3-nocov, 9 | 3.4-cover, 10 | 3.4-nocov, 11 | 3.5-cover, 12 | 3.5-nocov, 13 | pypy-cover, 14 | pypy-nocov, 15 | report, 16 | docs 17 | 18 | [testenv] 19 | basepython = 20 | {docs,spell}: python2.7 21 | {clean,check,report,extension-coveralls,coveralls,codecov}: python3.5 22 | setenv = 23 | PYTHONPATH={toxinidir}/tests 24 | PYTHONUNBUFFERED=yes 25 | passenv = 26 | * 27 | deps = 28 | pytest 29 | pytest-travis-fold 30 | commands = 31 | {posargs:py.test -vv --ignore=src} 32 | 33 | [testenv:spell] 34 | setenv = 35 | SPELLCHECK=1 36 | commands = 37 | sphinx-build -b spelling docs dist/docs 38 | skip_install = true 39 | usedevelop = false 40 | deps = 41 | -r{toxinidir}/docs/requirements.txt 42 | sphinxcontrib-spelling 43 | pyenchant 44 | 45 | [testenv:docs] 46 | deps = 47 | -r{toxinidir}/docs/requirements.txt 48 | commands = 49 | sphinx-build {posargs:-E} -b html docs dist/docs 50 | sphinx-build -b linkcheck docs dist/docs 51 | 52 | [testenv:bootstrap] 53 | deps = 54 | jinja2 55 | matrix 56 | skip_install = true 57 | usedevelop = false 58 | commands = 59 | python ci/bootstrap.py 60 | passenv = 61 | * 62 | 63 | [testenv:check] 64 | deps = 65 | docutils 66 | check-manifest 67 | flake8 68 | readme-renderer 69 | pygments 70 | isort 71 | skip_install = true 72 | usedevelop = false 73 | commands = 74 | python setup.py check --strict --metadata --restructuredtext 75 | check-manifest {toxinidir} 76 | flake8 src tests setup.py 77 | isort --verbose --check-only --diff --recursive src tests setup.py 78 | 79 | [testenv:coveralls] 80 | deps = 81 | coveralls 82 | skip_install = true 83 | usedevelop = false 84 | commands = 85 | coverage combine --append 86 | coverage report 87 | coveralls [] 88 | 89 | [testenv:codecov] 90 | deps = 91 | codecov 92 | skip_install = true 93 | usedevelop = false 94 | commands = 95 | coverage combine --append 96 | coverage report 97 | coverage xml --ignore-errors 98 | codecov [] 99 | 100 | 101 | [testenv:report] 102 | deps = coverage 103 | skip_install = true 104 | usedevelop = false 105 | commands = 106 | coverage combine --append 107 | coverage report 108 | coverage html 109 | 110 | [testenv:clean] 111 | commands = coverage erase 112 | skip_install = true 113 | usedevelop = false 114 | deps = coverage 115 | 116 | [testenv:2.7-cover] 117 | basepython = {env:TOXPYTHON:python2.7} 118 | setenv = 119 | {[testenv]setenv} 120 | WITH_COVERAGE=yes 121 | usedevelop = true 122 | commands = 123 | {posargs:py.test --cov --cov-report=term-missing -vv} 124 | deps = 125 | {[testenv]deps} 126 | pytest-cov 127 | httpretty 128 | 129 | [testenv:2.7-nocov] 130 | basepython = {env:TOXPYTHON:python2.7} 131 | deps = 132 | {[testenv]deps} 133 | httpretty 134 | 135 | [testenv:3.3-cover] 136 | basepython = {env:TOXPYTHON:python3.3} 137 | setenv = 138 | {[testenv]setenv} 139 | WITH_COVERAGE=yes 140 | usedevelop = true 141 | commands = 142 | {posargs:py.test --cov --cov-report=term-missing -vv} 143 | deps = 144 | {[testenv]deps} 145 | pytest-cov 146 | 147 | [testenv:3.3-nocov] 148 | basepython = {env:TOXPYTHON:python3.3} 149 | 150 | [testenv:3.4-cover] 151 | basepython = {env:TOXPYTHON:python3.4} 152 | setenv = 153 | {[testenv]setenv} 154 | WITH_COVERAGE=yes 155 | usedevelop = true 156 | commands = 157 | {posargs:py.test --cov --cov-report=term-missing -vv} 158 | deps = 159 | {[testenv]deps} 160 | pytest-cov 161 | 162 | [testenv:3.4-nocov] 163 | basepython = {env:TOXPYTHON:python3.4} 164 | 165 | [testenv:3.5-cover] 166 | basepython = {env:TOXPYTHON:python3.5} 167 | setenv = 168 | {[testenv]setenv} 169 | WITH_COVERAGE=yes 170 | usedevelop = true 171 | commands = 172 | {posargs:py.test --cov --cov-report=term-missing -vv} 173 | deps = 174 | {[testenv]deps} 175 | pytest-cov 176 | 177 | [testenv:3.5-nocov] 178 | basepython = {env:TOXPYTHON:python3.5} 179 | 180 | [testenv:pypy-cover] 181 | basepython = {env:TOXPYTHON:pypy} 182 | setenv = 183 | {[testenv]setenv} 184 | WITH_COVERAGE=yes 185 | usedevelop = true 186 | commands = 187 | {posargs:py.test --cov --cov-report=term-missing -vv} 188 | deps = 189 | {[testenv]deps} 190 | pytest-cov 191 | httpretty 192 | 193 | [testenv:pypy-nocov] 194 | basepython = {env:TOXPYTHON:pypy} 195 | deps = 196 | {[testenv]deps} 197 | httpretty 198 | 199 | 200 | --------------------------------------------------------------------------------