├── .coveragerc ├── .dockerignore ├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── release.yml ├── .gitignore ├── CHANGES.rst ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── README.rst ├── build_docker.sh ├── docs ├── Makefile └── source │ ├── _static │ └── .gitkeep │ ├── changes.rst │ ├── conf.py │ ├── index.rst │ └── modules.rst ├── examples ├── channels24_WAP1.png ├── channels5_WAP1.png ├── example_floorplan.png ├── example_with_marks.png ├── jitter_WAP1.png ├── quality_WAP1.png ├── rssi_WAP1.png ├── tcp_download_Mbps_WAP1.png ├── tcp_upload_Mbps_WAP1.png └── udp_Mbps_WAP1.png ├── pytest.ini ├── setup.cfg ├── setup.py └── wifi_survey_heatmap ├── __init__.py ├── collector.py ├── complete.oga ├── heatmap.py ├── libnl.py ├── scancli.py ├── thresholds.py ├── ui.py └── version.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | omit = lib/* 4 | wifi-survey-heatmap/tests/* 5 | setup.py 6 | 7 | [report] 8 | exclude_lines = 9 | # this cant ever be run by py.test, but it just calls one function, 10 | # so ignore it 11 | if __name__ == .__main__.: 12 | if sys.version_info.+ 13 | raise NotImplementedError 14 | except ImportError: 15 | .*# nocoverage.* 16 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | bin/ 12 | include/ 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | pip-selfcheck.json 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *,cover 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | 57 | # Sphinx documentation 58 | docs/build/ 59 | 60 | # PyBuilder 61 | target/ 62 | 63 | # virtualenv 64 | bin/ 65 | include/ 66 | 67 | .idea/ 68 | result.json 69 | *.json 70 | *.png 71 | docs/ 72 | examples/ -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing to wifi-survey-heatmap 2 | =============================== 3 | 4 | See the [Documentation on ReadTheDocs](http://wifi-survey-heatmap.readthedocs.org/en/master/index.html) for information on how to contribute. 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Please remove all of this template but the relevant section below, and fill in 2 | each item in that section. 3 | 4 | ## Feature Request 5 | 6 | ### Feature Description 7 | 8 | Describe in detail the feature you would like to see implemented, especially 9 | how it would work from a user perspective and what benefits it adds. Your description 10 | should be detailed enough to be used to determine if code written for the feature 11 | adequately solves the problem. 12 | 13 | ### Use Cases 14 | 15 | Describe one or more use cases for why this feature will be useful. 16 | 17 | ### Testing Assistance 18 | 19 | Indicate whether or not you will be able to assist in testing pre-release 20 | code for the feature. 21 | 22 | ## Bug Report 23 | 24 | When reporting a bug, please provide all of the following information, 25 | as well as any additional details that may be useful in reproducing or fixing 26 | the issue: 27 | 28 | ### Version 29 | 30 | wifi-survey-heatmap version, as reported by ``wifi-survey-heatmap --version`` 31 | 32 | ### Installation Method 33 | 34 | How was wifi-survey-heatmap installed (provide as much detail as possible, ideally 35 | the exact command used and whether it was installed in a virtualenv or not). 36 | 37 | ### Supporting Software Versions 38 | 39 | The output of ``python --version`` and ``virtualenv --version`` in the environment 40 | that wifi-survey-heatmap is running in, as well as your operating system type and version. 41 | 42 | ### Actual Output 43 | 44 | ``` 45 | Paste here the output of wifi-survey-heatmap (including the command used to run it), 46 | run with the -vv (debug-level output) flag, that shows the issue. 47 | ``` 48 | 49 | ### Expected Output 50 | 51 | Describe the output that you expected (what's wrong). If possible, after your description, 52 | copy the actual output above and modify it to what was expected. 53 | 54 | ### Testing Assistance 55 | 56 | Indicate whether or not you will be able to assist in testing pre-release 57 | code for the feature. 58 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report an issue/problem 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Bug Report 11 | 12 | When reporting a bug, please provide all of the following information, 13 | as well as any additional details that may be useful in reproducing or fixing 14 | the issue: 15 | 16 | ### Version 17 | 18 | wifi-survey-heatmap version, as reported by ``wifi-survey-heatmap --version`` 19 | 20 | ### Installation Method 21 | 22 | How was wifi-survey-heatmap installed (provide as much detail as possible, ideally 23 | the exact command used and whether it was installed in a virtualenv or not). 24 | 25 | ### Supporting Software Versions 26 | 27 | The output of ``python --version`` and ``virtualenv --version`` in the environment 28 | that wifi-survey-heatmap is running in, as well as your operating system type and version. 29 | 30 | ### Actual Output 31 | 32 | ``` 33 | Paste here the output of wifi-survey-heatmap (including the command used to run it), 34 | run with the -vv (debug-level output) flag, that shows the issue. 35 | ``` 36 | 37 | ### Expected Output 38 | 39 | Describe the output that you expected (what's wrong). If possible, after your description, 40 | copy the actual output above and modify it to what was expected. 41 | 42 | ### Testing Assistance 43 | 44 | Indicate whether or not you will be able to assist in testing pre-release 45 | code for the feature. 46 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEAT]" 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Feature Request 11 | 12 | ### Feature Description 13 | 14 | Describe in detail the feature you would like to see implemented, especially 15 | how it would work from a user perspective and what benefits it adds. Your description 16 | should be detailed enough to be used to determine if code written for the feature 17 | adequately solves the problem. 18 | 19 | ### Use Cases 20 | 21 | Describe one or more use cases for why this feature will be useful. 22 | 23 | ### Testing Assistance 24 | 25 | Indicate whether or not you will be able to assist in testing pre-release 26 | code for the feature. 27 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | __IMPORTANT:__ Please take note of the below checklist, especially the first two items. 2 | 3 | # Pull Request Checklist 4 | 5 | - [ ] All pull requests must include the Contributor License Agreement (see below). 6 | - [ ] Code should conform to the following: 7 | - [ ] pep8 compliant with some exceptions (see pytest.ini) 8 | - [ ] 100% test coverage with pytest (with valid tests). If you have difficulty 9 | writing tests for the code, feel free to ask for help or submit the PR without tests. 10 | - [ ] Complete, correctly-formatted documentation for all classes, functions and methods. 11 | - [ ] documentation has been rebuilt with ``tox -e docs`` 12 | - [ ] All modules should have (and use) module-level loggers. 13 | - [ ] **Commit messages** should be meaningful, and reference the Issue number 14 | if you're working on a GitHub issue (i.e. "issue #x - "). Please 15 | refrain from using the "fixes #x" notation unless you are *sure* that the 16 | the issue is fixed in that commit. 17 | - [ ] Git history is fully intact; please do not squash or rewrite history. 18 | 19 | ## Contributor License Agreement 20 | 21 | By submitting this work for inclusion in wifi-survey-heatmap, I agree to the following terms: 22 | 23 | * The contribution included in this request (and any subsequent revisions or versions of it) 24 | is being made under the same license as the wifi-survey-heatmap project (the Affero GPL v3, 25 | or any subsequent version of that license if adopted by wifi-survey-heatmap). 26 | * My contribution may perpetually be included in and distributed with wifi-survey-heatmap; submitting 27 | this pull request grants a perpetual, global, unlimited license for it to be used and distributed 28 | under the terms of wifi-survey-heatmap's license. 29 | * I have the legal power and rights to agree to these terms. 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # Workflow Requirements: 2 | # 3 | # 1. In repository Settings -> Actions -> General, ensure "Allow all actions and reusable workflows" is selected, and that under "Workflow permissions", "Read repository contents and packages permissions" is checked. 4 | # 2. On https://hub.docker.com click your username in the top right, then Account Settings, then select "Security" from the left nav menu; generate a new Access Token for this repo with read & write perms. 5 | # 3. In repository Settings -> Secrets and variables -> Actions, create a new Repository secret; call it ``DOCKERHUB_TOKEN`` and paste the Docker Hub access token you just created as the value. 6 | # 7 | name: Release on Tag 8 | on: 9 | push: 10 | tags: 11 | - '*' 12 | jobs: 13 | release: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: write 17 | packages: write 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Set up Docker Buildx 21 | uses: docker/setup-buildx-action@v2 22 | - name: Login to GHCR 23 | run: echo "${{secrets.GITHUB_TOKEN}}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin 24 | - name: Login to Docker Hub 25 | uses: docker/login-action@v2 26 | with: 27 | username: jantman 28 | password: ${{ secrets.DOCKERHUB_TOKEN }} 29 | - name: Docker Build and Push 30 | uses: docker/build-push-action@v4 31 | with: 32 | push: true 33 | sbom: true 34 | labels: | 35 | org.opencontainers.image.url=https://github.com/${{ github.repository }} 36 | org.opencontainers.image.source=https://github.com/${{ github.repository }} 37 | org.opencontainers.image.version=${{ github.ref_name }} 38 | org.opencontainers.image.revision=${{ github.sha }} 39 | tags: | 40 | ghcr.io/${{ github.repository }}:${{ github.ref_name }} 41 | ghcr.io/${{ github.repository }}:latest 42 | ${{ github.repository }}:${{ github.ref_name }} 43 | ${{ github.repository }}:latest 44 | - name: Create Release 45 | id: create_release 46 | uses: actions/create-release@v1 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token 49 | with: 50 | tag_name: ${{ github.ref }} 51 | release_name: Release ${{ github.ref }} 52 | body: | 53 | Release ${{ github.ref_name }}. 54 | 55 | **GitHub Package:** https://github.com/users/jantman/packages/container/package/machine-access-control 56 | 57 | **Docker Images:** 58 | ghcr.io/${{ github.repository }}:${{ github.ref_name }} 59 | ghcr.io/${{ github.repository }}:latest 60 | ${{ github.repository }}:${{ github.ref_name }} 61 | ${{ github.repository }}:latest 62 | draft: false 63 | prerelease: false 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | bin/ 12 | include/ 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | pip-selfcheck.json 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *,cover 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | 57 | # Sphinx documentation 58 | docs/build/ 59 | 60 | # PyBuilder 61 | target/ 62 | 63 | # virtualenv 64 | bin/ 65 | include/ 66 | 67 | .idea/ 68 | result.json 69 | *.json 70 | /jitter_*.png 71 | /quality_*.png 72 | /rssi_*.png 73 | /tcp_*.png 74 | /udp_*.png 75 | /channels*.png 76 | 77 | pyvenv.cfg 78 | venv/ 79 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 2.0.0 (2024-12-08) 5 | ------------------ 6 | 7 | * Bump base Docker image from Buster to Bullseye. 8 | * Merge `PR 34 `_ to include plot failure exception in logging, thanks to `josephw `_. 9 | * README - include link to similar project for MacOS. 10 | * Massive contribution from `byteit101 `_ in `PR 38 `_ to move the UI processing to a thread, resolve duplicate point plotting issues, better handle sudo, and many other fixes. 11 | 12 | 1.2.0 (2022-06-05) 13 | ------------------ 14 | 15 | * Merge `PR 26 `_ to add documentation on ``Couldn't connect to accessibility bus`` error, thanks to `hnykda `__. 16 | * Fix ``TypeError`` in ``wifi-heatmap-thresholds`` entrypoint. 17 | * Update all Python dependencies. 18 | * Update Docker image to latest Debian Buster. 19 | 20 | 1.1.0 (2022-04-05) 21 | ------------------ 22 | 23 | * Merge `PR 24 `_ to fix `Issue 22 `_ where APs were being plotted with SSID instead of BSSID, and therefore wifi-heatmap `--ap-names` option was not working. 24 | 25 | 1.0.0 (2022-02-19) 26 | ------------------ 27 | 28 | * Merge `PR 4 `_ and `PR 6 `_ containing a massive number of improvements by `DL6ER `__ 29 | 30 | * Migrate from iwconfig to iw-nl80211 (communicate directly with the kernel over sockets using PyRIC) 31 | * Add new optional parameter "wifi-survey -d 10" to support changing the duration of the iperf3 test runs. Often enough, 10 seconds did not result in reliable results if far away from the AP (it needs some "ramp up" time) 32 | * Modify wifi-heatmap to support new data in the JSON container 33 | * Add option "wifi-heatmap --show-points" to show points (hidden now by default as they are distracting without providing additional information on sufficiently dense measurements) 34 | * Add frequency_TITLE.png heatmap to show band-steering effects. 35 | * Add channel\_{rx,tx}\_bitrate_TITLE.png heatmap to show advertised channel capacity. 36 | * Add Quick Start to README (pointing more prominently towards docker) and other minor tweaks for the README 37 | * Use stead nlsocket for improved performance 38 | * Put try-except block around iwscan as it may fail with "OSError: [Errno 16] Error while scanning: Device or resource busy" 39 | * Show progress labels in survey circles 40 | * Ensure PEP8 compliance of all changes 41 | * RX and TX bandwidths are identical (channel-property), chose one of them (often only one value is available) 42 | * Reenable Download (UDP) test and use received_Mbps (instead of sent_Mbps) as measure for the most realistic bitrate 43 | * Add option for controling the colormap more easily 44 | * Finish transition from iwtools to nl80211 45 | * Update wifi-heatmap to read new data from JSON file 46 | * Don't interpolate uniform data to avoid interpolation artifacts for uniform data 47 | * Rename nl_scan to libnl, PEP8 formatting and allow to make a survey without an iperf3 server 48 | * Implement relative scaling of the window 49 | * Draw a red point if a survey failed. Remove failed points when starting a new survey. 50 | * Do not start maximized. This makes sense now that we support image scaling. 51 | * Change color of moving point from red to lightblue as we're now using red to indicate failure. 52 | * Make SERVER an optional property to easily skip iperf3 tests in case there is no suitable server. Also disable scanning by default as it takes quite some time and does not give all that much information in the end. Finally, update the README to reflect these changes 53 | * Add frequency graph and differentiate 54 | * Update Dockerfile to use patched version of libnl 55 | * Allow starting wifi-survey without any mandatory arguments. Missing items will be asked for in an interactive manner. 56 | * Store used image filename in JSON file so wifi-heatmaps works with TITLE alone. This can still be overwritten when explicitly specifying an image. 57 | * Add optional argument --contours N to draw N contours in the image (with inline labels) 58 | * Ensure survey points are drawn on top of everything else when they are used and that contour lines are omitted for uniform data. 59 | * Only try to plot for what we have data in the JSON file 60 | * Allow iperf3 server to be specified as ip:port 61 | 62 | * `PR 15 `_ from `chris-reeves `__ - Handle missing data points 63 | * Update Dockerfile 64 | * Fix `Issue 17 `_ - AttributeError when scanning APs, caused by unset/NoneType interface_name. 65 | * Update examples in README 66 | * Fix `Issue 18 `_ - AttributeError in ``wifi-heatmap`` entrypoint - 'HeatMapGenerator' object has no attribute '_image_path'. 67 | * Switch from using DL6ER's libnl fork to `libnl3 `__ 68 | * Fix `Issue 19 `_ - BSSID option was intermittently not working. This has been fixed. 69 | * Add command-line option to toggle libnl debug-level logging on or off (off by default). 70 | * Fix for non-integer screen positions when scaling. 71 | 72 | 0.2.1 (2020-08-11) 73 | ------------------ 74 | 75 | * Fix heatmap generation error if ``wifi-survey`` is run with ``-S`` / ``--no-scan`` option to disable ``iwlist scan``. 76 | * Implement ``-b`` / ``--bssid`` option to ensure that scan is against a specified BSSID. 77 | * Implement ``--ding`` option to play a short audio file (i.e. a ding) when measurement is finished. 78 | * ``wifi-heatmap`` - accept either title or filename as argument 79 | 80 | 0.2.0 (2020-08-09) 81 | ------------------ 82 | 83 | * Package via Docker, for easier usage without worrying about dependencies. 84 | * Optional AP name/band annotations on heatmap. 85 | * Add CLI option to disable iwlist scans. 86 | * Add ability to remove survey points. 87 | * Add ability to drag (move) survey points. 88 | 89 | 0.1.0 (2018-10-30) 90 | ------------------ 91 | 92 | * Initial release 93 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:bullseye 2 | 3 | ARG build_date 4 | ARG repo_url 5 | ARG repo_ref 6 | USER root 7 | 8 | RUN apt-get update && \ 9 | DEBIAN_FRONTEND=noninteractive \ 10 | apt-get install -y --no-install-recommends \ 11 | gcc \ 12 | g++ \ 13 | git \ 14 | iperf3 \ 15 | libjpeg-dev \ 16 | pulseaudio-utils \ 17 | python3 \ 18 | python3-cffi \ 19 | python3-dev \ 20 | python3-pip \ 21 | python3-scipy \ 22 | python3-setuptools \ 23 | python3-wheel \ 24 | python3-wxgtk4.0 \ 25 | wireless-tools \ 26 | zlib1g zlib1g-dev \ 27 | && rm -rf /var/lib/apt/lists/* 28 | 29 | RUN pip3 install iperf3 matplotlib wheel libnl3 30 | 31 | COPY . /app 32 | 33 | RUN cd /app && \ 34 | python3 setup.py develop && \ 35 | pip3 freeze > /app/requirements.installed 36 | 37 | LABEL maintainer="jason@jasonantman.com" \ 38 | org.label-schema.build-date="$build_date" \ 39 | org.label-schema.name="jantman/python-wifi-survey-heatmap" \ 40 | org.label-schema.url="https://github.com/jantman/python-wifi-survey-heatmap" \ 41 | org.label-schema.vcs-url="$repo_url" \ 42 | org.label-schema.vcs-ref="$repo_ref" \ 43 | org.label-schema.version="$repo_ref" \ 44 | org.label-schema.schema-version="1.0" 45 | 46 | # For the iperf server, if using for the server side 47 | EXPOSE 5201/tcp 48 | EXPOSE 5201/udp 49 | 50 | CMD /bin/bash 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | 663 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGES.rst 2 | include LICENSE 3 | include README.rst 4 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | python-wifi-survey-heatmap 2 | ========================== 3 | 4 | .. image:: https://www.repostatus.org/badges/latest/inactive.svg 5 | :alt: Project Status: Inactive – The project has reached a stable, usable state but is no longer being actively developed; support/maintenance will be provided as time allows. 6 | :target: https://www.repostatus.org/#inactive 7 | 8 | .. image:: https://img.shields.io/docker/cloud/build/jantman/python-wifi-survey-heatmap.svg 9 | :alt: Docker Hub Build Status 10 | :target: https://hub.docker.com/r/jantman/python-wifi-survey-heatmap 11 | 12 | A Python application for Linux machines to perform WiFi site surveys and present the results as a heatmap overlayed on a floorplan. 13 | 14 | This is rather rough "beta" code. The heatmap generation code is roughly based on 15 | `Beau Gunderson's MIT-licensed wifi-heatmap code `_. 16 | 17 | Many thanks to `DL6ER `__ who contributed a massive amount of improvements to this project. 18 | 19 | Operating System support 20 | ------------------------ 21 | 22 | As mentioned in the description, this project is for **LINUX ONLY**. That doesn't mean a Linux docker container on Windows or Mac, or running it on Mac. The WiFi features - the heart of this project - are built on `libnl3 `__, a Python wrapper around the Netlink protocol-based Linux kernel interfaces. In short, the survey commands will **only** work on a system that's running Linux, and where the Linux kernel is directly managing the WiFi hardware. 23 | 24 | For people not running Linux, I am aware of (but have no affiliation with, haven't used, and can't endorse) the following projects: 25 | 26 | * `hnykda/wifi-heatmapper `__ for MacOS 27 | 28 | Quick start 29 | ----------- 30 | 31 | Check out the **Running In Docker** steps below to get single-line commands that run without the need to install *anything* on your computer (thanks to using `docker`). 32 | Creating a heatmap using the software consists of the following three essential steps: 33 | 34 | 1. Start an `iperf3` server on any machine in your local network. This server is used for bandwidth measurements to be independent of your Internet connection. When omitting the `--server` option, this may be skipped, however, be aware that the performance heatmaps tpyically are the icing on the cake of your measurement and are very useful in determining the *real* performance of your WiFi. 35 | 2. Use the `wifi-survey` tool to record a measurement. You can load a floorplan and click on your current location ot record signal strength and determine the achievable bandwidth. 36 | 3. Once done with all the measurements, use the `wifi-heatmap` tool to compute a high-resolution heatmap from your recorded data. In case your data turns out to be too coarse, you can always go back to step 2 and delete or move old and also add new measurements at any time. 37 | 38 | Installation and Dependencies 39 | ----------------------------- 40 | 41 | **NOTE: These can all be ignored when using Docker. DOCKER IS THE RECOMMENDED INSTALLATION METHOD. See below.** 42 | 43 | * The Python `iperf3 `_ package, which needs `iperf3 `_ installed on your system. 44 | * The Python `libnl3 `_ package. 45 | * `wxPython Phoenix `_, which unfortunately must be installed using OS packages or built from source. 46 | * An iperf3 server running on another system on the LAN, as described below is recommended but optional. 47 | 48 | Recommended installation is via ``python setup.py develop`` in a virtualenv setup with ``--system-site-packages`` (for the above dependencies). 49 | 50 | Tested with Python 3.7. 51 | 52 | Data Collection 53 | --------------- 54 | 55 | At each survey location, data collection should take 45-60 seconds. The data collected is currently: 56 | 57 | * 10-second iperf3 measurement, TCP, client (this app) sending to server, default iperf3 options [optional, enable with `--server`] 58 | * 10-second iperf3 measurement, TCP, server sending to client, default iperf3 options [optional, enable with `--server`] 59 | * 10-second iperf3 measurement, UDP, client (this app) sending to server, default iperf3 options [optional, enable with `--server`] 60 | * Recording of various WiFi details such as advertised channel bandwidth, bitrate, or signal strength 61 | * Scan of all visible access points in the vicinity [optional, enable with `--scan`] 62 | 63 | Hints: 64 | - The duration of the bandwidth measurement can be changed using the `--duration` argument of `wifi-survey`. This has great influence on the actual length of the individual data collections. 65 | - Scanning for other network takes rather long. As this isn't required in most cases, it is not enabled by default 66 | 67 | Usage 68 | ----- 69 | 70 | Server Setup 71 | ++++++++++++ 72 | 73 | On the system you're using as the ``iperf3`` server, run ``iperf3 -s`` to start iperf3 in server mode in the foreground. 74 | By default it will use TCP and UDP ports 5201 for communication, and these must be open in your firewall (at least from the client machine). 75 | Ideally, you should be running the same exact iperf3 version on both machines. 76 | 77 | Performing a Survey 78 | +++++++++++++++++++ 79 | 80 | The survey tool (``wifi-survey``) only requires root privileges for scans. It can be run via ``sudo`` in which case it will drop back to your user after forking off the scan process, or it will launch the scan process via ``pkexec`` if not started with ``sudo`` (or via Docker; see below). 81 | 82 | First connect to the network that you want to survey. Then, run ``sudo wifi-survey`` where: 83 | 84 | Command-line options include: 85 | 86 | * ``-i INTERFACE`` / ``--interface INTERFACE`` is the name of your Wireless interface (e.g. ``wlp3s0``) 87 | * ``-p PICTURE`` / ``--picture PICTURE`` is the path to a floorplan PNG file to use as the background for the map; see `examples/example_floorplan.png `_ for an example. In order to compare multiple surveys it may be helpful to pre-mark your measurement points on the floorplan, like `examples/example_with_marks.png `_ below for details. 94 | 95 | If ``TITLE.json`` already exists, the data from it will be pre-loaded into the application; this can be used to **resume a survey**. 96 | 97 | When the UI loads, you should see your PNG file displayed. The UI is really simple: 98 | 99 | * If you (left / primary) click on a point on the PNG, this will begin a measurement (survey point). The application should draw a yellow circle there. The status bar at the bottom of the window will show information on each test as it's performed; the full cycle typically takes a minute or a bit more. When the test is complete, the circle should turn green and the status bar will inform you that the data has been written to ``Title.json`` and it's ready for the next measurement. If ``iperf3`` encounters an error, you'll be prompted whether you want to retry or not; if you don't, whatever results iperf was able to obtain will be saved for that point. 100 | * The output file is (re-)written after each measurement completes, so just exit the app when you're finished (or want to resume later; specifying the same Title will load the existing points and data from JSON). 101 | * Right (secondary) clicking a point will allow you to delete it. You'll be prompted to confirm. 102 | * Dragging (left/primary click and hold, then drag) an existing point will allow you to move it. You'll be prompted to confirm. This is handy if you accidentally click in the wrong place. 103 | 104 | At the end of the process, you should end up with a JSON file in your current directory named after the title you provided to ``wifi-survey`` (``Title.json``) that's owned by root. Fix the permissions if you want. 105 | 106 | **Note:** The actual survey methodology is largely up to you. In order to get accurate results, you likely want to manually handle AP associations yourself. Ideally, you lock your client to a single AP and single frequency/band for the survey. 107 | 108 | Playing A Sound When Measurement Finishes 109 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 110 | 111 | It's possible to have ``wifi-survey`` play a sound when each measurement is complete. This can be handy if you're reading or watching something in another window while waiting for the measurements. 112 | 113 | To enable this, call ``wifi-survey`` with the ``--ding`` argument, passing it the path to an audio file to play. A short sound effect is included in this repository at ``wifi_survey_heatmap/complete.oga`` and can be used via ``--ding wifi_survey_heatmap/complete.oga``. by default, this will call ``/usr/bin/paplay`` (the PulseAudio player) passing it the ding file path as the only argument. The command used can be overridden with ``--ding-command /path/to/command`` but it must be one that accepts the path to an audio file as its only argument. If you launch the scan as your user or via ``sudo``, the UI & the PulseAudio client will be run as your user and work without further configuration. If you run as root not via ``sudo``, then additional PuseAudo configuration may be necessary. 114 | 115 | Inside Docker, however, this becomes quite a bit more difficult. Currently PulseAudio systems are supported, and this can be set up and enabled with the following steps: 116 | 117 | 1. Find your host computer's IP address on the ``docker0`` network: ``ip addr show dev docker0`` - mine (and most Linux machines) is ``172.17.0.1`` 118 | 1. Find the CIDR block of your ``docker0`` network. I do this using ``ip route show dev docker0``, which gives me a CIDR of ``172.17.0.0/16`` 119 | 1. Have PulseAudio listen on a TCP socket, allowing connections from your Docker network: ``pactl load-module module-native-protocol-tcp port=34567 auth-ip-acl=172.17.0.0/16`` 120 | 1. If you have iptables restricting traffic, insert a rule allowing traffic on port 34567 from Docker before your ``DROP`` rule. For example, to insert a rule at position 5 in the ``INPUT`` chain: ``iptables -I INPUT 5 -s 172.17.0.0/16 -p tcp -m multiport --dports 34567 -m comment --comment "accept PulseAudio port 34567 tcp from Docker" -j ACCEPT`` 121 | 1. When running the Docker container, add ``-e "PULSE_SERVER=tcp:172.17.0.1:34567"`` to the ``docker run`` command. 122 | 1. When running ``wifi-survey``, add the ``--ding`` argument as specified above. Note that the path to the file must be inside the container; you can put an audio file in your current directory and use it via ``--ding /pwd/audioFile`` or you can use the default file built-in to the container via ``--ding /app/wifi_survey_heatmap/complete.oga`` 123 | 124 | Heatmap Generation 125 | ++++++++++++++++++ 126 | 127 | Once you've performed a survey with a given title and the results are saved in ``Title.json``, run ``wifi-heatmap TITLE`` to generate heatmap files in the current directory. This process does not require (and shouldn't have) root/sudo and operates only on the JSON data file. For this, it will look better if you use a PNG without the measurement location marks. 128 | 129 | You can optionally pass the path to a JSON file mapping the access point MAC addresses (BSSIDs) to friendly names via the ``-a`` / ``--ap-names`` argument. If specified, this will annotate each measurement dot on the heatmap with the name (mapping value) and frequency band of the AP that was connected when the measurement was taken. This can be useful in multi-AP roaming environments. 130 | 131 | The end result of this process for a given survey (Title) should be some ``.png`` images in your current directory: 132 | 133 | * `channels24_TITLE.png` - Bar graph of average signal quality of APs seen on 2.4 GHz channels, by channel. Useful for visualizing channel contention. (Based on 20 MHz channel bandwidth) 134 | * `channels5_TITLE.png` - Bar graph of average signal quality of APs seen on 5 GHz channels, by channel. Useful for visualizing channel contention. (Based on per-channel bandwidth from 20 to 160 MHz) 135 | * `signal_quality_TITLE.png` - Heatmap based on the received signal strength. 136 | * `tx_power_TITLE.png` - Heatmap based on the transmitter power your WiFi card used. If your WiFi card doe snot support adaptive power management, this number will stay constant. 137 | * `tcp_download_Mbps_TITLE.png` - Heatmap of `iperf3` transfer rate, TCP, downloading from server to client. 138 | * `tcp_upload_Mbps_TITLE.png` - Heatmap of `iperf3` transfer rate, TCP, uploading from client to server. 139 | * `udp_download_Mbps_TITLE.png` - Heatmap of `iperf3` transfer rate, UDP, downloading from server to client. 140 | * `udp_upload_Mbps_TITLE.png` - Heatmap of `iperf3` transfer rate, UDP, uploading from client to server. 141 | * `jitter_download_TITLE.png` - Heatmap based on UDP jitter measurement in milliseconds. 142 | * `jitter_upload_TITLE.png` - Heatmap based on UDP jitter measurement in milliseconds. 143 | * `frequency_TITLE.png` - Heatmap of used frequency. May reveal zones in which Wi-Fi steering moved the device onto a different band (2.4GHz / 5 GHz co-existance). 144 | * `channel_bitrate_TITLE.png` - Heatmap of negotiated channel bandwidth 145 | 146 | If you'd like to synchronize the colors/thresholds across multiple heatmaps, such as when comparing different AP placements, you can run ``wifi-heatmap-thresholds`` passing it each of the titles / output JSON filenames. This will generate a ``thresholds.json`` file in the current directory, suitable for passing to the ``wifi-heatmap`` ``-t`` / ``--thresholds`` option. 147 | 148 | Add `--show-points` to see the measurement points in the generated maps. Typically, they aren't important when you have a sufficiently dense grid of points so they are hidden by default. 149 | 150 | Running In Docker 151 | ----------------- 152 | 153 | Survey 154 | ++++++ 155 | 156 | Note the 157 | 158 | .. code-block:: bash 159 | 160 | docker run \ 161 | --net="host" \ 162 | --privileged \ 163 | --name survey \ 164 | -it \ 165 | --rm \ 166 | -v $(pwd):/pwd \ 167 | -w /pwd \ 168 | -e DISPLAY=$DISPLAY \ 169 | -v "$HOME/.Xauthority:/root/.Xauthority:ro" \ 170 | jantman/python-wifi-survey-heatmap \ 171 | wifi-survey -b -i -s -p -t 172 | 173 | Note that running with ``--net="host"`` and ``--privileged`` is required in order to manipulate the host's wireless interface. 174 | 175 | Heatmap 176 | +++++++ 177 | 178 | ``docker run -it --rm -v $(pwd):/pwd -w /pwd jantman/python-wifi-survey-heatmap:23429a4 wifi-heatmap <TITLE>`` 179 | 180 | iperf3 server 181 | +++++++++++++ 182 | 183 | Server: ``docker run -it --rm -p 5201:5201/tcp -p 5201:5201/udp jantman/python-wifi-survey-heatmap iperf3 -s`` 184 | 185 | Examples 186 | -------- 187 | 188 | Floorplan 189 | +++++++++ 190 | 191 | .. image:: examples/example_floorplan.png 192 | :alt: example floorplan image 193 | 194 | Floorplan with Measurement Marks 195 | ++++++++++++++++++++++++++++++++ 196 | 197 | .. image:: examples/example_with_marks.png 198 | :alt: example floorplan image with measurement marks 199 | 200 | 2.4 GHz Channels 201 | ++++++++++++++++ 202 | 203 | .. image:: examples/channels24_WAP1.png 204 | :alt: example 2.4 GHz channel usage 205 | 206 | 5 GHz Channels 207 | ++++++++++++++ 208 | 209 | .. image:: examples/channels5_WAP1.png 210 | :alt: example 5 GHz channel usage 211 | 212 | Jitter 213 | ++++++ 214 | 215 | .. image:: examples/jitter_WAP1.png 216 | :alt: example jitter heatmap 217 | 218 | Quality 219 | +++++++ 220 | 221 | .. image:: examples/quality_WAP1.png 222 | :alt: example quality heatmap 223 | 224 | RSSI / Signal Strength 225 | ++++++++++++++++++++++ 226 | 227 | .. image:: examples/rssi_WAP1.png 228 | :alt: example rssi heatmap 229 | 230 | TCP Download Speed (Mbps) 231 | +++++++++++++++++++++++++ 232 | 233 | .. image:: examples/tcp_download_Mbps_WAP1.png 234 | :alt: example tcp download heatmap 235 | 236 | TCP Upload Speed (Mbps) 237 | +++++++++++++++++++++++ 238 | 239 | .. image:: examples/tcp_upload_Mbps_WAP1.png 240 | :alt: example tcp upload heatmap 241 | 242 | UDP Upload Speed (Mbps) 243 | +++++++++++++++++++++++ 244 | 245 | .. image:: examples/udp_Mbps_WAP1.png 246 | :alt: example udp upload heatmap 247 | 248 | Issues 249 | ------ 250 | 251 | If you see: 252 | 253 | .. code-block:: bash 254 | 255 | Couldn't connect to accessibility bus: Failed to connect to socket /run/user/1000/at-spi/bus_0: No such file or directory 256 | 257 | when running in docker, mount the socket in docker explicitly by adding an additional `-v` switch: 258 | 259 | .. code-block:: bash 260 | 261 | docker run ... -v /run/user/1000/at-spi/bus_0:/run/user/1000/at-spi/bus_0 ... 262 | 263 | Release Process 264 | --------------- 265 | 266 | 1. Merge all PRs desired in the release. 267 | 2. Update ``CHANGES.rst``, commit, push. 268 | 3. Tag the repo with the version number and push. GitHub Actions will build and push the Docker image and create a Release. 269 | -------------------------------------------------------------------------------- /build_docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -ex 2 | 3 | cd "$( dirname "${BASH_SOURCE[0]}" )" 4 | BDATE=$(date +%Y-%m-%dT%H:%M:%S.00%Z) 5 | REF=$(git rev-parse --short HEAD) 6 | if ! git diff --no-ext-diff --quiet --exit-code || ! git diff-index --cached --quiet HEAD --; then 7 | REF="${REF}-dirty" 8 | fi 9 | 10 | docker build \ 11 | --build-arg "build_date=${BDATE}" \ 12 | --build-arg "repo_url=$(git config remote.origin.url)" \ 13 | --build-arg "repo_ref=${REF}" \ 14 | -t jantman/python-wifi-survey-heatmap:${REF} \ 15 | . 16 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext 23 | 24 | help: 25 | @echo "Please use \`make <target>' where <target> is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " applehelp to make an Apple Help Book" 34 | @echo " devhelp to make HTML files and a Devhelp project" 35 | @echo " epub to make an epub" 36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 37 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 39 | @echo " text to make text files" 40 | @echo " man to make manual pages" 41 | @echo " texinfo to make Texinfo files" 42 | @echo " info to make Texinfo files and run them through makeinfo" 43 | @echo " gettext to make PO message catalogs" 44 | @echo " changes to make an overview of all changed/added/deprecated items" 45 | @echo " xml to make Docutils-native XML files" 46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 47 | @echo " linkcheck to check all external links for integrity" 48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 49 | @echo " coverage to run coverage check of the documentation (if enabled)" 50 | 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | html: 55 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 56 | @echo 57 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 58 | 59 | dirhtml: 60 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 61 | @echo 62 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 63 | 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | pickle: 70 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 71 | @echo 72 | @echo "Build finished; now you can process the pickle files." 73 | 74 | json: 75 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 76 | @echo 77 | @echo "Build finished; now you can process the JSON files." 78 | 79 | htmlhelp: 80 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 81 | @echo 82 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 83 | ".hhp project file in $(BUILDDIR)/htmlhelp." 84 | 85 | qthelp: 86 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 87 | @echo 88 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 89 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 90 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/wifi-survey-heatmap.qhcp" 91 | @echo "To view the help file:" 92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/wifi-survey-heatmap.qhc" 93 | 94 | applehelp: 95 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 96 | @echo 97 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 98 | @echo "N.B. You won't be able to view it unless you put it in" \ 99 | "~/Library/Documentation/Help or install it in your application" \ 100 | "bundle." 101 | 102 | devhelp: 103 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 104 | @echo 105 | @echo "Build finished." 106 | @echo "To view the help file:" 107 | @echo "# mkdir -p $$HOME/.local/share/devhelp/wifi-survey-heatmap" 108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/wifi-survey-heatmap" 109 | @echo "# devhelp" 110 | 111 | epub: 112 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 113 | @echo 114 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 115 | 116 | latex: 117 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 118 | @echo 119 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 120 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 121 | "(use \`make latexpdf' here to do that automatically)." 122 | 123 | latexpdf: 124 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 125 | @echo "Running LaTeX files through pdflatex..." 126 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 127 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 128 | 129 | latexpdfja: 130 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 131 | @echo "Running LaTeX files through platex and dvipdfmx..." 132 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 133 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 134 | 135 | text: 136 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 137 | @echo 138 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 139 | 140 | man: 141 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 142 | @echo 143 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 144 | 145 | texinfo: 146 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 147 | @echo 148 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 149 | @echo "Run \`make' in that directory to run these through makeinfo" \ 150 | "(use \`make info' here to do that automatically)." 151 | 152 | info: 153 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 154 | @echo "Running Texinfo files through makeinfo..." 155 | make -C $(BUILDDIR)/texinfo info 156 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 157 | 158 | gettext: 159 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 160 | @echo 161 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 162 | 163 | changes: 164 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 165 | @echo 166 | @echo "The overview file is in $(BUILDDIR)/changes." 167 | 168 | linkcheck: 169 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 170 | @echo 171 | @echo "Link check complete; look for any errors in the above output " \ 172 | "or in $(BUILDDIR)/linkcheck/output.txt." 173 | 174 | doctest: 175 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 176 | @echo "Testing of doctests in the sources finished, look at the " \ 177 | "results in $(BUILDDIR)/doctest/output.txt." 178 | 179 | coverage: 180 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 181 | @echo "Testing of coverage in the sources finished, look at the " \ 182 | "results in $(BUILDDIR)/coverage/python.txt." 183 | 184 | xml: 185 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 186 | @echo 187 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 188 | 189 | pseudoxml: 190 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 191 | @echo 192 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 193 | -------------------------------------------------------------------------------- /docs/source/_static/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jantman/python-wifi-survey-heatmap/2e63b2a776037630e9df487722605124472ea8c6/docs/source/_static/.gitkeep -------------------------------------------------------------------------------- /docs/source/changes.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../CHANGES.rst 2 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # wifi-survey-heatmap documentation build configuration file, created by 4 | # sphinx-quickstart on Sat Jun 6 16:12:56 2015. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | import re 18 | # to let sphinx find the actual source... 19 | sys.path.insert(0, os.path.abspath("../..")) 20 | from wifi_survey_heatmap.version import VERSION 21 | import sphinx.environment 22 | from docutils.utils import get_source_line 23 | 24 | # If extensions (or modules to document with autodoc) are in another directory, 25 | # add these directories to sys.path here. If the directory is relative to the 26 | # documentation root, use os.path.abspath to make it absolute, like shown here. 27 | #sys.path.insert(0, os.path.abspath('.')) 28 | 29 | is_rtd = os.environ.get('READTHEDOCS', None) != 'True' 30 | readthedocs_version = os.environ.get('READTHEDOCS_VERSION', '') 31 | 32 | rtd_version = VERSION 33 | 34 | if (readthedocs_version in ['stable', 'latest', 'master'] or 35 | re.match(r'^\d+\.\d+\.\d+', readthedocs_version)): 36 | # this is a tag or stable/latest/master; show the actual version 37 | rtd_version = VERSION 38 | 39 | # -- General configuration ------------------------------------------------ 40 | 41 | # If your documentation needs a minimal Sphinx version, state it here. 42 | #needs_sphinx = '1.0' 43 | 44 | # Add any Sphinx extension module names here, as strings. They can be 45 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 46 | # ones. 47 | extensions = [ 48 | 'sphinx.ext.autodoc', 49 | 'sphinx.ext.intersphinx', 50 | 'sphinx.ext.todo', 51 | 'sphinx.ext.coverage', 52 | 'sphinx.ext.viewcode', 53 | ] 54 | 55 | # Add any paths that contain templates here, relative to this directory. 56 | templates_path = ['_templates'] 57 | 58 | # The suffix(es) of source filenames. 59 | # You can specify multiple suffix as a list of string: 60 | # source_suffix = ['.rst', '.md'] 61 | source_suffix = '.rst' 62 | 63 | # The encoding of source files. 64 | #source_encoding = 'utf-8-sig' 65 | 66 | # The master toctree document. 67 | master_doc = 'index' 68 | 69 | # General information about the project. 70 | project = u'wifi-survey-heatmap' 71 | copyright = u'2018 Jason Antman' 72 | author = u'Jason Antman' 73 | 74 | # The version info for the project you're documenting, acts as replacement for 75 | # |version| and |release|, also used in various other places throughout the 76 | # built documents. 77 | # 78 | # The short X.Y version. 79 | version = rtd_version 80 | # The full version, including alpha/beta/rc tags. 81 | release = version 82 | 83 | # The language for content autogenerated by Sphinx. Refer to documentation 84 | # for a list of supported languages. 85 | # 86 | # This is also used if you do content translation via gettext catalogs. 87 | # Usually you set "language" from the command line for these cases. 88 | language = None 89 | 90 | # There are two options for replacing |today|: either, you set today to some 91 | # non-false value, then it is used: 92 | #today = '' 93 | # Else, today_fmt is used as the format for a strftime call. 94 | #today_fmt = '%B %d, %Y' 95 | 96 | # List of patterns, relative to source directory, that match files and 97 | # directories to ignore when looking for source files. 98 | exclude_patterns = [] 99 | 100 | # The reST default role (used for this markup: `text`) to use for all 101 | # documents. 102 | #default_role = None 103 | 104 | # If true, '()' will be appended to :func: etc. cross-reference text. 105 | #add_function_parentheses = True 106 | 107 | # If true, the current module name will be prepended to all description 108 | # unit titles (such as .. function::). 109 | #add_module_names = True 110 | 111 | # If true, sectionauthor and moduleauthor directives will be shown in the 112 | # output. They are ignored by default. 113 | #show_authors = False 114 | 115 | # The name of the Pygments (syntax highlighting) style to use. 116 | pygments_style = 'sphinx' 117 | 118 | # A list of ignored prefixes for module index sorting. 119 | #modindex_common_prefix = [] 120 | 121 | # If true, keep warnings as "system message" paragraphs in the built documents. 122 | #keep_warnings = False 123 | 124 | # If true, `todo` and `todoList` produce output, else they produce nothing. 125 | todo_include_todos = True 126 | 127 | 128 | # -- Options for HTML output ---------------------------------------------- 129 | 130 | if is_rtd: 131 | import sphinx_rtd_theme 132 | html_theme = 'sphinx_rtd_theme' 133 | html_theme_path = [ 134 | sphinx_rtd_theme.get_html_theme_path(), 135 | ] 136 | html_static_path = ['_static'] 137 | htmlhelp_basename = 'wifi-survey-heatmapdoc' 138 | 139 | #html_theme_options = { 140 | # 'analytics_id': 'Your-ID-Here', 141 | #} 142 | 143 | # The name for this set of Sphinx documents. If None, it defaults to 144 | # "<project> v<release> documentation". 145 | html_title = 'v{v} - Description of Package Here'.format(v=version) 146 | 147 | # Add any extra paths that contain custom files (such as robots.txt or 148 | # .htaccess) here, relative to this directory. These files are copied 149 | # directly to the root of the documentation. 150 | #html_extra_path = [] 151 | 152 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 153 | # using the given strftime format. 154 | html_last_updated_fmt = '%b %d, %Y' 155 | 156 | # If true, SmartyPants will be used to convert quotes and dashes to 157 | # typographically correct entities. 158 | #html_use_smartypants = True 159 | 160 | # Custom sidebar templates, maps document names to template names. 161 | #html_sidebars = {} 162 | 163 | # Additional templates that should be rendered to pages, maps page names to 164 | # template names. 165 | #html_additional_pages = {} 166 | 167 | # If false, no module index is generated. 168 | #html_domain_indices = True 169 | 170 | # If false, no index is generated. 171 | #html_use_index = True 172 | 173 | # If true, the index is split into individual pages for each letter. 174 | #html_split_index = False 175 | 176 | # If true, links to the reST sources are added to the pages. 177 | #html_show_sourcelink = True 178 | 179 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 180 | #html_show_sphinx = True 181 | 182 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 183 | #html_show_copyright = True 184 | 185 | # If true, an OpenSearch description file will be output, and all pages will 186 | # contain a <link> tag referring to it. The value of this option must be the 187 | # base URL from which the finished HTML is served. 188 | #html_use_opensearch = '' 189 | 190 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 191 | #html_file_suffix = None 192 | 193 | # Language to be used for generating the HTML full-text search index. 194 | # Sphinx supports the following languages: 195 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' 196 | # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' 197 | #html_search_language = 'en' 198 | 199 | # A dictionary with options for the search language support, empty by default. 200 | # Now only 'ja' uses this config value 201 | #html_search_options = {'type': 'default'} 202 | 203 | # The name of a javascript file (relative to the configuration directory) that 204 | # implements a search results scorer. If empty, the default will be used. 205 | #html_search_scorer = 'scorer.js' 206 | 207 | # Output file base name for HTML help builder. 208 | #htmlhelp_basename = 'wifi-survey-heatmapdoc' 209 | 210 | # -- Options for LaTeX output --------------------------------------------- 211 | 212 | latex_elements = { 213 | # The paper size ('letterpaper' or 'a4paper'). 214 | #'papersize': 'letterpaper', 215 | 216 | # The font size ('10pt', '11pt' or '12pt'). 217 | #'pointsize': '10pt', 218 | 219 | # Additional stuff for the LaTeX preamble. 220 | #'preamble': '', 221 | 222 | # Latex figure (float) alignment 223 | #'figure_align': 'htbp', 224 | } 225 | 226 | # Grouping the document tree into LaTeX files. List of tuples 227 | # (source start file, target name, title, 228 | # author, documentclass [howto, manual, or own class]). 229 | latex_documents = [ 230 | (master_doc, 'wifi-survey-heatmap.tex', u'wifi-survey-heatmap Documentation', 231 | u'Jason Antman', 'manual'), 232 | ] 233 | 234 | # The name of an image file (relative to this directory) to place at the top of 235 | # the title page. 236 | #latex_logo = None 237 | 238 | # For "manual" documents, if this is true, then toplevel headings are parts, 239 | # not chapters. 240 | #latex_use_parts = False 241 | 242 | # If true, show page references after internal links. 243 | #latex_show_pagerefs = False 244 | 245 | # If true, show URL addresses after external links. 246 | #latex_show_urls = False 247 | 248 | # Documents to append as an appendix to all manuals. 249 | #latex_appendices = [] 250 | 251 | # If false, no module index is generated. 252 | #latex_domain_indices = True 253 | 254 | 255 | # -- Options for manual page output --------------------------------------- 256 | 257 | # One entry per manual page. List of tuples 258 | # (source start file, name, description, authors, manual section). 259 | man_pages = [ 260 | (master_doc, 'wifi-survey-heatmap', u'wifi-survey-heatmap Documentation', 261 | [author], 1) 262 | ] 263 | 264 | # If true, show URL addresses after external links. 265 | #man_show_urls = False 266 | 267 | 268 | # -- Options for Texinfo output ------------------------------------------- 269 | 270 | # Grouping the document tree into Texinfo files. List of tuples 271 | # (source start file, target name, title, author, 272 | # dir menu entry, description, category) 273 | texinfo_documents = [ 274 | (master_doc, 'wifi-survey-heatmap', u'wifi-survey-heatmap Documentation', 275 | author, 'wifi-survey-heatmap', 'One line description of project.', 276 | 'Miscellaneous'), 277 | ] 278 | 279 | # Documents to append as an appendix to all manuals. 280 | #texinfo_appendices = [] 281 | 282 | # If false, no module index is generated. 283 | #texinfo_domain_indices = True 284 | 285 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 286 | #texinfo_show_urls = 'footnote' 287 | 288 | # If true, do not generate a @detailmenu in the "Top" node's menu. 289 | #texinfo_no_detailmenu = False 290 | 291 | 292 | # Example configuration for intersphinx: refer to the Python standard library. 293 | intersphinx_mapping = { 294 | 'https://docs.python.org/3/': None, 295 | } 296 | 297 | autoclass_content = 'class' 298 | autodoc_default_flags = ['members', 'undoc-members', 'private-members', 'show-inheritance'] 299 | 300 | linkcheck_ignore = [ 301 | r'https?://landscape\.io.*', 302 | r'https?://www\.virtualenv\.org.*', 303 | r'https?://.*\.readthedocs\.org.*', 304 | r'https?://codecov\.io.*', 305 | r'https?://.*readthedocs\.org.*', 306 | r'https?://pypi\.python\.org/pypi/wifi-survey-heatmap' 307 | ] 308 | 309 | # exclude module docstrings - see http://stackoverflow.com/a/18031024/211734 310 | def remove_module_docstring(app, what, name, obj, options, lines): 311 | if what == "module": 312 | del lines[:] 313 | 314 | # ignore non-local image warnings 315 | def _warn_node(self, msg, node, **kwargs): 316 | if not msg.startswith('nonlocal image URI found:'): 317 | self._warnfunc(msg, '%s:%s' % get_source_line(node)) 318 | 319 | sphinx.environment.BuildEnvironment.warn_node = _warn_node 320 | 321 | def setup(app): 322 | app.connect("autodoc-process-docstring", remove_module_docstring) 323 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. meta:: 2 | :description: Description of wifi-survey-heatmap goes here. 3 | 4 | .. include:: ../../README.rst 5 | 6 | Contents 7 | ========= 8 | 9 | .. toctree:: 10 | :maxdepth: 4 11 | 12 | API <modules> 13 | Changelog <changes> 14 | 15 | Indices and tables 16 | ================== 17 | 18 | * :ref:`genindex` 19 | * :ref:`modindex` 20 | * :ref:`search` 21 | -------------------------------------------------------------------------------- /docs/source/modules.rst: -------------------------------------------------------------------------------- 1 | wifi-survey-heatmap 2 | =============== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | wifi_survey_heatmap 8 | -------------------------------------------------------------------------------- /examples/channels24_WAP1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jantman/python-wifi-survey-heatmap/2e63b2a776037630e9df487722605124472ea8c6/examples/channels24_WAP1.png -------------------------------------------------------------------------------- /examples/channels5_WAP1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jantman/python-wifi-survey-heatmap/2e63b2a776037630e9df487722605124472ea8c6/examples/channels5_WAP1.png -------------------------------------------------------------------------------- /examples/example_floorplan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jantman/python-wifi-survey-heatmap/2e63b2a776037630e9df487722605124472ea8c6/examples/example_floorplan.png -------------------------------------------------------------------------------- /examples/example_with_marks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jantman/python-wifi-survey-heatmap/2e63b2a776037630e9df487722605124472ea8c6/examples/example_with_marks.png -------------------------------------------------------------------------------- /examples/jitter_WAP1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jantman/python-wifi-survey-heatmap/2e63b2a776037630e9df487722605124472ea8c6/examples/jitter_WAP1.png -------------------------------------------------------------------------------- /examples/quality_WAP1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jantman/python-wifi-survey-heatmap/2e63b2a776037630e9df487722605124472ea8c6/examples/quality_WAP1.png -------------------------------------------------------------------------------- /examples/rssi_WAP1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jantman/python-wifi-survey-heatmap/2e63b2a776037630e9df487722605124472ea8c6/examples/rssi_WAP1.png -------------------------------------------------------------------------------- /examples/tcp_download_Mbps_WAP1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jantman/python-wifi-survey-heatmap/2e63b2a776037630e9df487722605124472ea8c6/examples/tcp_download_Mbps_WAP1.png -------------------------------------------------------------------------------- /examples/tcp_upload_Mbps_WAP1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jantman/python-wifi-survey-heatmap/2e63b2a776037630e9df487722605124472ea8c6/examples/tcp_upload_Mbps_WAP1.png -------------------------------------------------------------------------------- /examples/udp_Mbps_WAP1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jantman/python-wifi-survey-heatmap/2e63b2a776037630e9df487722605124472ea8c6/examples/udp_Mbps_WAP1.png -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | pep8ignore = 3 | lib/* ALL 4 | lib64/* ALL 5 | vendor/* ALL 6 | docs/* ALL 7 | pep8maxlinelength = 80 8 | 9 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | The latest version of this package is available at: 3 | <http://github.com/jantman/wifi-survey-heatmap> 4 | 5 | ################################################################################## 6 | Copyright 2017 Jason Antman <jason@jasonantman.com> <http://www.jasonantman.com> 7 | 8 | This file is part of wifi-survey-heatmap, also known as wifi-survey-heatmap. 9 | 10 | wifi-survey-heatmap is free software: you can redistribute it and/or modify 11 | it under the terms of the GNU Affero General Public License as published by 12 | the Free Software Foundation, either version 3 of the License, or 13 | (at your option) any later version. 14 | 15 | wifi-survey-heatmap is distributed in the hope that it will be useful, 16 | but WITHOUT ANY WARRANTY; without even the implied warranty of 17 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 | GNU Affero General Public License for more details. 19 | 20 | You should have received a copy of the GNU Affero General Public License 21 | along with wifi-survey-heatmap. If not, see <http://www.gnu.org/licenses/>. 22 | 23 | The Copyright and Authors attributions contained herein may not be removed or 24 | otherwise altered, except to add the Author attribution of a contributor to 25 | this work. (Additional Terms pursuant to Section 7b of the AGPL v3) 26 | ################################################################################## 27 | While not legally required, I sincerely request that anyone who finds 28 | bugs please submit them at <https://github.com/jantman/wifi-survey-heatmap> or 29 | to me via email, and that you send any contributions or improvements 30 | either as a pull request on GitHub, or to me via email. 31 | ################################################################################## 32 | 33 | AUTHORS: 34 | Jason Antman <jason@jasonantman.com> <http://www.jasonantman.com> 35 | ################################################################################## 36 | """ 37 | 38 | from setuptools import setup, find_packages 39 | from wifi_survey_heatmap.version import VERSION, PROJECT_URL 40 | 41 | with open('README.rst') as file: 42 | long_description = file.read() 43 | 44 | requires = [ 45 | 'iperf3==0.1.11', 46 | 'matplotlib==3.5.2', 47 | 'scipy==1.8.1', 48 | 'libnl3==0.3.0', 49 | 'pypubsub==4.0.3', 50 | ] 51 | 52 | classifiers = [ 53 | 'Development Status :: 1 - Planning', 54 | 'Environment :: X11 Applications :: GTK', 55 | 'Intended Audience :: End Users/Desktop', 56 | 'Intended Audience :: Information Technology', 57 | 'Intended Audience :: System Administrators', 58 | 'License :: OSI Approved :: GNU Affero General Public License ' 59 | 'v3 or later (AGPLv3+)', 60 | 'Natural Language :: English', 61 | 'Operating System :: POSIX :: Linux', 62 | 'Programming Language :: Python', 63 | 'Programming Language :: Python :: 2.7', 64 | 'Programming Language :: Python :: 3', 65 | 'Programming Language :: Python :: 3.4', 66 | 'Programming Language :: Python :: 3.5', 67 | 'Programming Language :: Python :: 3.6', 68 | 'Programming Language :: Python :: 3.7', 69 | 'Programming Language :: Python :: 3.8', 70 | 'Programming Language :: Python :: 3.9', 71 | 'Programming Language :: Python :: 3.10', 72 | 'Topic :: System :: Networking' 73 | ] 74 | 75 | setup( 76 | name='wifi-survey-heatmap', 77 | version=VERSION, 78 | author='Jason Antman', 79 | author_email='jason@jasonantman.com', 80 | packages=find_packages(), 81 | url=PROJECT_URL, 82 | description='A Python application for Linux machines to perform WiFi site' 83 | ' surveys and present the results as a heatmap overlayed on ' 84 | 'a floorplan.', 85 | long_description=long_description, 86 | install_requires=requires, 87 | setup_requires=['cffi>=1.0.0'], 88 | keywords="wifi wireless wlan survey map heatmap", 89 | classifiers=classifiers, 90 | entry_points={ 91 | 'console_scripts': [ 92 | 'wifi-scan = wifi_survey_heatmap.scancli:main', 93 | 'wifi-survey = wifi_survey_heatmap.ui:main', 94 | 'wifi-heatmap = wifi_survey_heatmap.heatmap:main', 95 | 'wifi-heatmap-thresholds = wifi_survey_heatmap.thresholds:main' 96 | ] 97 | }, 98 | zip_safe=False 99 | ) 100 | -------------------------------------------------------------------------------- /wifi_survey_heatmap/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | The latest version of this package is available at: 3 | <http://github.com/jantman/wifi-survey-heatmap> 4 | 5 | ################################################################################## 6 | Copyright 2018 Jason Antman <jason@jasonantman.com> <http://www.jasonantman.com> 7 | 8 | This file is part of wifi-survey-heatmap, also known as wifi-survey-heatmap. 9 | 10 | wifi-survey-heatmap is free software: you can redistribute it and/or modify 11 | it under the terms of the GNU Affero General Public License as published by 12 | the Free Software Foundation, either version 3 of the License, or 13 | (at your option) any later version. 14 | 15 | wifi-survey-heatmap is distributed in the hope that it will be useful, 16 | but WITHOUT ANY WARRANTY; without even the implied warranty of 17 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 | GNU Affero General Public License for more details. 19 | 20 | You should have received a copy of the GNU Affero General Public License 21 | along with wifi-survey-heatmap. If not, see <http://www.gnu.org/licenses/>. 22 | 23 | The Copyright and Authors attributions contained herein may not be removed or 24 | otherwise altered, except to add the Author attribution of a contributor to 25 | this work. (Additional Terms pursuant to Section 7b of the AGPL v3) 26 | ################################################################################## 27 | While not legally required, I sincerely request that anyone who finds 28 | bugs please submit them at <https://github.com/jantman/wifi-survey-heatmap> or 29 | to me via email, and that you send any contributions or improvements 30 | either as a pull request on GitHub, or to me via email. 31 | ################################################################################## 32 | 33 | AUTHORS: 34 | Jason Antman <jason@jasonantman.com> <http://www.jasonantman.com> 35 | ################################################################################## 36 | """ 37 | -------------------------------------------------------------------------------- /wifi_survey_heatmap/collector.py: -------------------------------------------------------------------------------- 1 | """ 2 | The latest version of this package is available at: 3 | <http://github.com/jantman/wifi-survey-heatmap> 4 | 5 | ################################################################################## 6 | Copyright 2018 Jason Antman <jason@jasonantman.com> <http://www.jasonantman.com> 7 | 8 | This file is part of wifi-survey-heatmap, also known as wifi-survey-heatmap. 9 | 10 | wifi-survey-heatmap is free software: you can redistribute it and/or modify 11 | it under the terms of the GNU Affero General Public License as published by 12 | the Free Software Foundation, either version 3 of the License, or 13 | (at your option) any later version. 14 | 15 | wifi-survey-heatmap is distributed in the hope that it will be useful, 16 | but WITHOUT ANY WARRANTY; without even the implied warranty of 17 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 | GNU Affero General Public License for more details. 19 | 20 | You should have received a copy of the GNU Affero General Public License 21 | along with wifi-survey-heatmap. If not, see <http://www.gnu.org/licenses/>. 22 | 23 | The Copyright and Authors attributions contained herein may not be removed or 24 | otherwise altered, except to add the Author attribution of a contributor to 25 | this work. (Additional Terms pursuant to Section 7b of the AGPL v3) 26 | ################################################################################## 27 | While not legally required, I sincerely request that anyone who finds 28 | bugs please submit them at <https://github.com/jantman/wifi-survey-heatmap> or 29 | to me via email, and that you send any contributions or improvements 30 | either as a pull request on GitHub, or to me via email. 31 | ################################################################################## 32 | 33 | AUTHORS: 34 | Jason Antman <jason@jasonantman.com> <http://www.jasonantman.com> 35 | ################################################################################## 36 | """ 37 | 38 | import logging 39 | from time import sleep 40 | 41 | from wifi_survey_heatmap.libnl import Scanner 42 | 43 | import iperf3 44 | 45 | logger = logging.getLogger(__name__) 46 | 47 | 48 | class Collector(object): 49 | 50 | def __init__(self, server_addr, duration, scanner, scan=True): 51 | super().__init__() 52 | logger.debug( 53 | 'Initializing Collector for interface: %s; iperf server: %s', 54 | scanner.interface_name, server_addr 55 | ) 56 | # ensure interface_name is a wireless interfaces 57 | self._iperf_server = server_addr 58 | self._scan = scan 59 | self._duration = duration 60 | self.scanner = scanner 61 | 62 | def run_iperf(self, udp=False, reverse=False): 63 | client = iperf3.Client() 64 | client.duration = self._duration 65 | 66 | server_parts = self._iperf_server.split(":") 67 | if len(server_parts) == 2: 68 | client.server_hostname = server_parts[0] 69 | client.port = int(server_parts[1]) 70 | else: 71 | client.server_hostname = self._iperf_server 72 | client.port = 5201 # substitute some default port 73 | 74 | client.protocol = 'udp' if udp else 'tcp' 75 | client.reverse = reverse 76 | logger.info( 77 | 'Running iperf to %s; udp=%s reverse=%s', self._iperf_server, 78 | udp, reverse 79 | ) 80 | for retry in range(0, 4): 81 | res = client.run() 82 | if res.error is None: 83 | break 84 | logger.error('iperf error %s; retrying', res.error) 85 | logger.debug('iperf result: %s', res) 86 | return res 87 | 88 | def check_associated(self): 89 | logger.debug('Checking association with AP...') 90 | if self.scanner.get_current_bssid() is None: 91 | logger.warning('Not associated to an AP') 92 | return False 93 | else: 94 | logger.debug("OK") 95 | return True 96 | 97 | def get_metrics(self): 98 | return self.scanner.get_iface_data() 99 | 100 | def scan_all_access_points(self): 101 | logger.debug('Scanning...') 102 | res = self.scanner.scan_all_access_points() 103 | logger.debug('Found {} access points during scan'.format(len(res))) 104 | return res 105 | -------------------------------------------------------------------------------- /wifi_survey_heatmap/complete.oga: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jantman/python-wifi-survey-heatmap/2e63b2a776037630e9df487722605124472ea8c6/wifi_survey_heatmap/complete.oga -------------------------------------------------------------------------------- /wifi_survey_heatmap/heatmap.py: -------------------------------------------------------------------------------- 1 | """ 2 | The latest version of this package is available at: 3 | <http://github.com/jantman/wifi-survey-heatmap> 4 | 5 | ################################################################################## 6 | Copyright 2017 Jason Antman <jason@jasonantman.com> <http://www.jasonantman.com> 7 | 8 | This file is part of wifi-survey-heatmap, also known as wifi-survey-heatmap. 9 | 10 | wifi-survey-heatmap is free software: you can redistribute it and/or modify 11 | it under the terms of the GNU Affero General Public License as published by 12 | the Free Software Foundation, either version 3 of the License, or 13 | (at your option) any later version. 14 | 15 | wifi-survey-heatmap is distributed in the hope that it will be useful, 16 | but WITHOUT ANY WARRANTY; without even the implied warranty of 17 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 | GNU Affero General Public License for more details. 19 | 20 | You should have received a copy of the GNU Affero General Public License 21 | along with wifi-survey-heatmap. If not, see <http://www.gnu.org/licenses/>. 22 | 23 | The Copyright and Authors attributions contained herein may not be removed or 24 | otherwise altered, except to add the Author attribution of a contributor to 25 | this work. (Additional Terms pursuant to Section 7b of the AGPL v3) 26 | ################################################################################## 27 | While not legally required, I sincerely request that anyone who finds 28 | bugs please submit them at <https://github.com/jantman/wifi-survey-heatmap> or 29 | to me via email, and that you send any contributions or improvements 30 | either as a pull request on GitHub, or to me via email. 31 | ################################################################################## 32 | 33 | AUTHORS: 34 | Jason Antman <jason@jasonantman.com> <http://www.jasonantman.com> 35 | ################################################################################## 36 | """ 37 | 38 | import sys 39 | import argparse 40 | import logging 41 | import json 42 | import numpy 43 | 44 | from collections import defaultdict 45 | import numpy as np 46 | import matplotlib.cm as cm 47 | import matplotlib.pyplot as pp 48 | from mpl_toolkits.axes_grid1 import make_axes_locatable 49 | from scipy.interpolate import Rbf 50 | from pylab import imread, imshow 51 | from matplotlib.offsetbox import AnchoredText 52 | from matplotlib.patheffects import withStroke 53 | from matplotlib.font_manager import FontManager 54 | from matplotlib.colors import ListedColormap 55 | import matplotlib 56 | 57 | 58 | FORMAT = "[%(asctime)s %(levelname)s] %(message)s" 59 | logging.basicConfig(level=logging.WARNING, format=FORMAT) 60 | logger = logging.getLogger() 61 | 62 | 63 | WIFI_CHANNELS = { 64 | # center frequency to (channel, bandwidth MHz) 65 | 2412.0: (1, 20.0), 66 | 2417.0: (2, 20.0), 67 | 2422.0: (3, 20.0), 68 | 2427.0: (4, 20.0), 69 | 2432.0: (5, 20.0), 70 | 2437.0: (6, 20.0), 71 | 2442.0: (7, 20.0), 72 | 2447.0: (8, 20.0), 73 | 2452.0: (9, 20.0), 74 | 2457.0: (10, 20.0), 75 | 2462.0: (11, 20.0), 76 | 2467.0: (12, 20.0), 77 | 2472.0: (13, 20.0), 78 | 2484.0: (14, 20.0), 79 | 5160.0: (32, 20.0), 80 | 5170.0: (34, 40.0), 81 | 5180.0: (36, 20.0), 82 | 5190.0: (38, 40.0), 83 | 5200.0: (40, 20.0), 84 | 5210.0: (42, 80.0), 85 | 5220.0: (44, 20.0), 86 | 5230.0: (46, 40.0), 87 | 5240.0: (48, 20.0), 88 | 5250.0: (50, 160.0), 89 | 5260.0: (52, 20.0), 90 | 5270.0: (54, 40.0), 91 | 5280.0: (56, 20.0), 92 | 5290.0: (58, 80.0), 93 | 5300.0: (60, 20.0), 94 | 5310.0: (62, 40.0), 95 | 5320.0: (64, 20.0), 96 | 5340.0: (68, 20.0), 97 | 5480.0: (96, 20.0), 98 | 5500.0: (100, 20.0), 99 | 5510.0: (102, 40.0), 100 | 5520.0: (104, 20.0), 101 | 5530.0: (106, 80.0), 102 | 5540.0: (108, 20.0), 103 | 5550.0: (110, 40.0), 104 | 5560.0: (112, 20.0), 105 | 5570.0: (114, 160.0), 106 | 5580.0: (116, 20.0), 107 | 5590.0: (118, 40.0), 108 | 5600.0: (120, 20.0), 109 | 5610.0: (122, 80.0), 110 | 5620.0: (124, 20.0), 111 | 5630.0: (126, 40.0), 112 | 5640.0: (128, 20.0), 113 | 5660.0: (132, 20.0), 114 | 5670.0: (134, 40.0), 115 | 5680.0: (136, 20.0), 116 | 5690.0: (138, 80.0), 117 | 5700.0: (140, 20.0), 118 | 5710.0: (142, 40.0), 119 | 5720.0: (144, 20.0), 120 | 5745.0: (149, 20.0), 121 | 5755.0: (151, 40.0), 122 | 5765.0: (153, 20.0), 123 | 5775.0: (155, 80.0), 124 | 5785.0: (157, 20.0), 125 | 5795.0: (159, 40.0), 126 | 5805.0: (161, 20.0), 127 | 5825.0: (165, 20.0) 128 | } 129 | 130 | 131 | class HeatMapGenerator(object): 132 | 133 | graphs = { 134 | 'signal_quality': 'Signal quality [%]', 135 | 'tx_power': 'TX power [dBm]', 136 | 'tcp_download_Mbps': 'Download (TCP) [MBit/s]', 137 | 'udp_download_Mbps': 'Download (UDP) [MBit/s]', 138 | 'tcp_upload_Mbps': 'Upload (TCP) [MBit/s]', 139 | 'udp_upload_Mbps': 'Upload (UDP) [MBit/s]', 140 | 'jitter_download': 'UDP Download Jitter [ms]', 141 | 'jitter_upload': 'UDP Upload Jitter [ms]', 142 | 'frequency': 'Wi-Fi frequency [GHz]', 143 | 'channel': 'Wi-Fi channel', 144 | 'channel_bitrate': 'Maximum channel bandwidth [MBit/s]', 145 | } 146 | 147 | def __init__( 148 | self, image_path, title, showpoints, cname, contours, ignore_ssids=[], aps=None, 149 | thresholds=None, hidebssid=False 150 | ): 151 | self._ap_names = {} 152 | if aps is not None: 153 | with open(aps, 'r') as fh: 154 | self._ap_names = { 155 | x.upper(): y for x, y in json.loads(fh.read()).items() 156 | } 157 | self._layout = None 158 | self._image_width = 0 159 | self._image_height = 0 160 | self._corners = [(0, 0), (0, 0), (0, 0), (0, 0)] 161 | self._title = title 162 | self._showpoints = showpoints 163 | self._cmap = self.get_cmap(cname) 164 | self._contours = contours 165 | if not self._title.endswith('.json'): 166 | self._title += '.json' 167 | self._ignore_ssids = ignore_ssids 168 | self._hidebssid = hidebssid 169 | logger.debug( 170 | 'Initialized HeatMapGenerator; title=%s', 171 | self._title 172 | ) 173 | with open(self._title, 'r') as fh: 174 | self._data = json.loads(fh.read()) 175 | if 'survey_points' not in self._data: 176 | logger.error('No survey points found in {}'.format(self._title)) 177 | exit() 178 | logger.info('Loaded %d survey points', 179 | len(self._data['survey_points'])) 180 | 181 | # Try to load image from JSON if not overwritten 182 | if image_path is None: 183 | if 'img_path' not in self._data: 184 | logger.error('No image path found in {}'.format(self._title)) 185 | exit(1) 186 | self._image_path = self._data['img_path'] 187 | else: 188 | self._image_path = image_path 189 | 190 | self.thresholds = {} 191 | if thresholds is not None: 192 | logger.info('Loading thresholds from: %s', thresholds) 193 | with open(thresholds, 'r') as fh: 194 | self.thresholds = json.loads(fh.read()) 195 | logger.debug('Thresholds: %s', self.thresholds) 196 | 197 | def get_cmap(self, cname): 198 | multi_string = cname.split('//') 199 | if len(multi_string) == 2: 200 | cname = multi_string[0] 201 | steps = int(multi_string[1]) 202 | N = 256 203 | colormap = cm.get_cmap(cname, N) 204 | newcolors = colormap(np.linspace(0, 1, N)) 205 | rgba = np.array([0, 0, 0, 1]) 206 | interval = int(N/steps) if steps > 0 else 0 207 | for i in range(0,N,interval): 208 | newcolors[i] = rgba 209 | print(newcolors) 210 | return ListedColormap(newcolors) 211 | else: 212 | return pp.get_cmap(cname) 213 | 214 | def load_data(self): 215 | a = defaultdict(list) 216 | check = set() 217 | for row in self._data['survey_points']: 218 | point = (row['x'], row['y']) 219 | if point in check: 220 | logger.warning(f"Two overlapping datapoints found. Discarding one of them. point={point}") 221 | continue 222 | check.add(point) 223 | a['x'].append(row['x']) 224 | a['y'].append(row['y']) 225 | a['channel'].append(row['result']['channel']) 226 | if 'tcp' in row['result']: 227 | a['tcp_upload_Mbps'].append( 228 | row['result']['tcp']['received_Mbps'] 229 | ) 230 | if 'tcp-reverse' in row['result']: 231 | a['tcp_download_Mbps'].append( 232 | row['result']['tcp-reverse']['received_Mbps'] 233 | ) 234 | if 'udp' in row['result']: 235 | a['udp_download_Mbps'].append(row['result']['udp']['Mbps']) 236 | a['jitter_download'].append(row['result']['udp']['jitter_ms']) 237 | if 'udp-reverse' in row['result']: 238 | a['udp_upload_Mbps'].append( 239 | row['result']['udp-reverse']['Mbps']) 240 | a['jitter_upload'].append( 241 | row['result']['udp-reverse']['jitter_ms']) 242 | a['tx_power'].append(row['result']['tx_power']) 243 | a['frequency'].append(row['result']['frequency']*1e-3) 244 | if 'bitrate' in row['result']: 245 | a['channel_bitrate'].append(row['result']['bitrate']) 246 | a['signal_quality'].append(row['result']['signal_mbm']+130) 247 | ap = self._ap_names.get( 248 | row['result']['mac'].upper(), 249 | row['result']['mac'] 250 | ) 251 | a['ap'].append(ap + ' ({0:.1f} GHz)'.format(1e-3*int(row['result']['frequency']))) 252 | return a 253 | 254 | def _load_image(self): 255 | self._layout = imread(self._image_path) 256 | self._image_width = len(self._layout[0]) 257 | self._image_height = len(self._layout) - 1 258 | self._corners = [ 259 | (0, 0), (0, self._image_height), 260 | (self._image_width, 0), (self._image_width, self._image_height) 261 | ] 262 | logger.debug( 263 | 'Loaded image with width=%d height=%d', 264 | self._image_width, self._image_height 265 | ) 266 | 267 | def generate(self): 268 | self._load_image() 269 | a = self.load_data() 270 | for x, y in self._corners: 271 | a['x'].append(x) 272 | a['y'].append(y) 273 | for k in a.keys(): 274 | if k in ['x', 'y', 'ap']: 275 | continue 276 | a['ap'].append(None) 277 | a[k] = [0 if x is None else x for x in a[k]] 278 | a[k].append(min(a[k])) 279 | self._channel_graphs() 280 | num_x = int(self._image_width / 4) 281 | num_y = int(num_x / (self._image_width / self._image_height)) 282 | x = np.linspace(0, self._image_width, num_x) 283 | y = np.linspace(0, self._image_height, num_y) 284 | gx, gy = np.meshgrid(x, y) 285 | gx, gy = gx.flatten(), gy.flatten() 286 | for k, ptitle in self.graphs.items(): 287 | try: 288 | self._plot( 289 | a, k, '%s - %s' % (self._title, ptitle), gx, gy, num_x, num_y 290 | ) 291 | except: 292 | logger.warning( 293 | "Cannot create %s plot: insufficient data", 294 | k, 295 | exc_info=True, 296 | ) 297 | 298 | def _channel_to_signal(self): 299 | """ 300 | Return a dictionary of 802.11 channel number to combined "quality" value 301 | for all APs seen on the given channel. This includes interpolation to 302 | overlapping channels based on channel width of each channel. 303 | """ 304 | # build a dict of frequency (GHz) to list of quality values 305 | channels = defaultdict(list) 306 | for row in self._data['survey_points']: 307 | for scan in row['result']['scan_results']: 308 | ssid = row['result']['scan_results'][scan]['ssid'] 309 | if ssid in self._ignore_ssids: 310 | continue 311 | freq = row['result']['scan_results'][scan]['frequency'] / 1e6 312 | channels[int(freq)].append( 313 | row['result']['scan_results'][scan]['signal_mbm'] + 100 314 | ) 315 | # collapse down to dict of frequency (GHz) to average quality (float) 316 | for freq in channels.keys(): 317 | channels[freq] = sum(channels[freq]) / len(channels[freq]) 318 | # build the full dict of frequency to quality for all channels 319 | freq_qual = {x: 0.0 for x in WIFI_CHANNELS.keys()} 320 | # then, update to account for full bandwidth of each channel 321 | for freq, qual in channels.items(): 322 | freq_qual[freq] += qual 323 | for spread in range( 324 | int(freq - (WIFI_CHANNELS[freq][1] / 2.0)), 325 | int(freq + (WIFI_CHANNELS[freq][1] / 2.0) + 1.0) 326 | ): 327 | if spread in freq_qual and spread != freq: 328 | freq_qual[spread] += qual 329 | return { 330 | WIFI_CHANNELS[x][0]: freq_qual[x] for x in freq_qual.keys() 331 | } 332 | 333 | def _plot_channels(self, names, values, title, fname, ticks): 334 | pp.rcParams['figure.figsize'] = ( 335 | self._image_width / 300, self._image_height / 300 336 | ) 337 | fig, ax = pp.subplots() 338 | ax.set_title(title) 339 | ax.bar(names, values) 340 | ax.set_xlabel('Channel') 341 | ax.set_ylabel('Mean Quality') 342 | ax.set_xticks(ticks) 343 | # ax.set_xticklabels(names) 344 | logger.info('Writing plot to: %s', fname) 345 | pp.savefig(fname, dpi=300) 346 | pp.close('all') 347 | 348 | def _channel_graphs(self): 349 | try: 350 | c2s = self._channel_to_signal() 351 | except KeyError: 352 | return 353 | names24 = [] 354 | values24 = [] 355 | names5 = [] 356 | values5 = [] 357 | for ch, val in c2s.items(): 358 | if ch < 15: 359 | names24.append(ch) 360 | values24.append(val) 361 | else: 362 | names5.append(ch) 363 | values5.append(val) 364 | self._plot_channels( 365 | names24, values24, '2.4GHz Channel Utilization', 366 | '%s_%s.png' % ('channels24', self._title), 367 | names24 368 | ) 369 | ticks5 = [ 370 | 38, 46, 54, 62, 102, 110, 118, 126, 134, 142, 151, 159 371 | ] 372 | self._plot_channels( 373 | names5, values5, '5GHz Channel Utilization', 374 | '%s_%s.png' % ('channels5', self._title), 375 | ticks5 376 | ) 377 | 378 | def _add_inner_title(self, ax, title, loc, size=None, **kwargs): 379 | if size is None: 380 | size = dict(size=pp.rcParams['legend.fontsize']) 381 | at = AnchoredText( 382 | title, loc=loc, prop=size, pad=0., borderpad=0.5, frameon=False, 383 | **kwargs 384 | ) 385 | at.set_zorder(200) 386 | ax.add_artist(at) 387 | at.txt._text.set_path_effects( 388 | [withStroke(foreground="w", linewidth=3)] 389 | ) 390 | return at 391 | 392 | def _plot(self, a, key, title, gx, gy, num_x, num_y): 393 | if key not in a: 394 | logger.info("Skipping {} due to insufficient data".format(key)) 395 | return 396 | if not len(a['x']) == len(a['y']) == len(a[key]): 397 | logger.info("Skipping {} because data has holes".format(key)) 398 | return 399 | logger.debug('Plotting: %s', key) 400 | pp.rcParams['figure.figsize'] = ( 401 | self._image_width / 300, self._image_height / 300 402 | ) 403 | fig, ax = pp.subplots() 404 | ax.set_title(title) 405 | if 'min' in self.thresholds.get(key, {}): 406 | vmin = self.thresholds[key]['min'] 407 | logger.debug('Using min threshold from thresholds: %s', vmin) 408 | else: 409 | vmin = min(a[key]) 410 | logger.debug('Using calculated min threshold: %s', vmin) 411 | if 'max' in self.thresholds.get(key, {}): 412 | vmax = self.thresholds[key]['max'] 413 | logger.debug('Using max threshold from thresholds: %s', vmax) 414 | else: 415 | vmax = max(a[key]) 416 | logger.debug('Using calculated max threshold: %s', vmax) 417 | logger.info("{} has range [{},{}]".format(key, vmin, vmax)) 418 | # Interpolate the data only if there is something to interpolate 419 | if vmin != vmax: 420 | rbf = Rbf( 421 | a['x'], a['y'], a[key], function='linear' 422 | ) 423 | z = rbf(gx, gy) 424 | z = z.reshape((num_y, num_x)) 425 | else: 426 | # Uniform array with the same color everywhere 427 | # (avoids interpolation artifacts) 428 | z = numpy.ones((num_y, num_x))*vmin 429 | # Render the interpolated data to the plot 430 | ax.axis('off') 431 | # begin color mapping 432 | norm = matplotlib.colors.Normalize(vmin=vmin, vmax=vmax, clip=True) 433 | mapper = cm.ScalarMappable(norm=norm, cmap=self._cmap) 434 | # end color mapping 435 | image = ax.imshow( 436 | z, 437 | extent=(0, self._image_width, self._image_height, 0), 438 | alpha=0.5, zorder=100, 439 | cmap=self._cmap, vmin=vmin, vmax=vmax 440 | ) 441 | 442 | # Draw contours if requested and meaningful in this plot 443 | if self._contours is not None and vmin != vmax: 444 | CS = ax.contour(z, colors='k', linewidths=1, levels=self._contours, 445 | extent=(0, self._image_width, self._image_height, 0), 446 | alpha=0.3, zorder=150, origin='upper') 447 | ax.clabel(CS, inline=1, fontsize=6) 448 | cbar = fig.colorbar(image) 449 | 450 | # Print only one ytick label when there is only one value to be shown 451 | if vmin == vmax: 452 | cbar.set_ticks([vmin]) 453 | 454 | # Draw floorplan itself to the lowest layer with full opacity 455 | ax.imshow(self._layout, interpolation='bicubic', zorder=1, alpha=1) 456 | labelsize = FontManager.get_default_size() * 0.4 457 | if(self._showpoints): 458 | # begin plotting points 459 | for idx in range(0, len(a['x'])): 460 | if (a['x'][idx], a['y'][idx]) in self._corners: 461 | continue 462 | ax.plot( 463 | a['x'][idx], a['y'][idx], zorder=200, 464 | marker='o', markeredgecolor='black', markeredgewidth=1, 465 | markerfacecolor=mapper.to_rgba(a[key][idx]), markersize=6 466 | ) 467 | if not self._hidebssid: 468 | ax.text( 469 | a['x'][idx], a['y'][idx] - 30, 470 | a['ap'][idx], fontsize=labelsize, 471 | horizontalalignment='center' 472 | ) 473 | # end plotting points 474 | fname = '%s_%s.png' % (key, self._title) 475 | logger.info('Writing plot to: %s', fname) 476 | pp.savefig(fname, dpi=300) 477 | pp.close('all') 478 | 479 | 480 | def parse_args(argv): 481 | """ 482 | parse arguments/options 483 | 484 | this uses the new argparse module instead of optparse 485 | see: <https://docs.python.org/2/library/argparse.html> 486 | """ 487 | p = argparse.ArgumentParser(description='wifi survey heatmap generator') 488 | p.add_argument('-v', '--verbose', dest='verbose', action='count', default=0, 489 | help='verbose output. specify twice for debug-level output.') 490 | p.add_argument('-i', '--ignore', dest='ignore', action='append', 491 | default=[], help='SSIDs to ignore from channel graph') 492 | p.add_argument('-t', '--thresholds', dest='thresholds', action='store', 493 | type=str, help='thresholds JSON file path') 494 | p.add_argument('-a', '--ap-names', type=str, dest='aps', action='store', 495 | default=None, 496 | help='If specified, a JSON file mapping AP MAC/BSSID to ' 497 | 'a string to label each measurement with, showing ' 498 | 'which AP it was connected to. Useful when doing ' 499 | 'multi-AP surveys.') 500 | p.add_argument('-c', '--cmap', type=str, dest='CNAME', action='store', 501 | default="RdYlBu_r", 502 | help='If specified, a valid matplotlib colormap name.') 503 | p.add_argument('-n', '--contours', type=int, dest='N', action='store', 504 | default=None, 505 | help='If specified, N contour lines will be added to the graphs') 506 | p.add_argument('-p', '--picture', dest='IMAGE', type=str, action='store', 507 | default=None, help='Path to background image') 508 | p.add_argument( 509 | 'TITLE', type=str, help='Title for survey (and data filename)' 510 | ) 511 | p.add_argument('-s', '--show-points', dest='showpoints', action='count', 512 | default=0, help='show measurement points in file') 513 | p.add_argument('-H', '--hide-bssid', dest='hidebssid', action='store_true', 514 | default=False, help='Hide the default point information') 515 | args = p.parse_args(argv) 516 | return args 517 | 518 | 519 | def set_log_info(): 520 | """set logger level to INFO""" 521 | set_log_level_format(logging.INFO, 522 | '%(asctime)s %(levelname)s:%(name)s:%(message)s') 523 | 524 | 525 | def set_log_debug(): 526 | """set logger level to DEBUG, and debug-level output format""" 527 | set_log_level_format( 528 | logging.DEBUG, 529 | "%(asctime)s [%(levelname)s %(filename)s:%(lineno)s - " 530 | "%(name)s.%(funcName)s() ] %(message)s" 531 | ) 532 | 533 | 534 | def set_log_level_format(level, format): 535 | """ 536 | Set logger level and format. 537 | 538 | :param level: logging level; see the :py:mod:`logging` constants. 539 | :type level: int 540 | :param format: logging formatter format string 541 | :type format: str 542 | """ 543 | formatter = logging.Formatter(fmt=format) 544 | logger.handlers[0].setFormatter(formatter) 545 | logger.setLevel(level) 546 | 547 | 548 | def main(): 549 | args = parse_args(sys.argv[1:]) 550 | 551 | # set logging level 552 | if args.verbose > 1: 553 | set_log_debug() 554 | elif args.verbose == 1: 555 | set_log_info() 556 | 557 | showpoints = True if args.showpoints > 0 else False 558 | 559 | HeatMapGenerator( 560 | args.IMAGE, args.TITLE, showpoints, args.CNAME, args.N, 561 | ignore_ssids=args.ignore, aps=args.aps, thresholds=args.thresholds, hidebssid=args.hidebssid 562 | ).generate() 563 | 564 | 565 | if __name__ == '__main__': 566 | main() 567 | -------------------------------------------------------------------------------- /wifi_survey_heatmap/libnl.py: -------------------------------------------------------------------------------- 1 | """ 2 | The latest version of this package is available at: 3 | <http://github.com/jantman/wifi-survey-heatmap> 4 | 5 | ################################################################################## 6 | Copyright 2020 Dominik DL6ER <dl6er@dl6er.de> 7 | 8 | This file is part of wifi-survey-heatmap, also known as wifi-survey-heatmap. 9 | 10 | wifi-survey-heatmap is free software: you can redistribute it and/or modify 11 | it under the terms of the GNU Affero General Public License as published by 12 | the Free Software Foundation, either version 3 of the License, or 13 | (at your option) any later version. 14 | 15 | wifi-survey-heatmap is distributed in the hope that it will be useful, 16 | but WITHOUT ANY WARRANTY; without even the implied warranty of 17 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 | GNU Affero General Public License for more details. 19 | 20 | You should have received a copy of the GNU Affero General Public License 21 | along with wifi-survey-heatmap. If not, see <http://www.gnu.org/licenses/>. 22 | 23 | The Copyright and Authors attributions contained herein may not be removed or 24 | otherwise altered, except to add the Author attribution of a contributor to 25 | this work. (Additional Terms pursuant to Section 7b of the AGPL v3) 26 | ################################################################################## 27 | While not legally required, I sincerely request that anyone who finds 28 | bugs please submit them at <https://github.com/jantman/wifi-survey-heatmap> or 29 | to me via email, and that you send any contributions or improvements 30 | either as a pull request on GitHub, or to me via email. 31 | ################################################################################## 32 | 33 | This code was inspired by the examples in https://github.com/DL6ER/libnl 34 | but is heavily rewritten to be a lot more general. 35 | 36 | AUTHORS: 37 | Dominik DL6ER <dl6er@dl6er.de> 38 | ################################################################################## 39 | """ 40 | 41 | import ctypes 42 | import fcntl 43 | import logging 44 | import socket 45 | import struct 46 | import time 47 | import datetime 48 | 49 | # For scanning access points in the vicinity 50 | import libnl.handlers 51 | from libnl.attr import ( 52 | nla_data, nla_get_string, nla_get_u8, nla_get_u16, nla_get_u32, nla_put_u32, 53 | nla_parse, nla_parse_nested, nla_put, nla_put_nested 54 | ) 55 | from libnl.error import errmsg 56 | from libnl.genl.ctrl import genl_ctrl_resolve, genl_ctrl_resolve_grp 57 | from libnl.genl.genl import ( 58 | genl_connect, genlmsg_attrdata, genlmsg_attrlen, genlmsg_put 59 | ) 60 | from libnl.linux_private.genetlink import genlmsghdr 61 | from libnl.linux_private.netlink import NLM_F_DUMP 62 | from libnl.msg import nlmsg_alloc, nlmsg_data, nlmsg_hdr 63 | from libnl.nl import nl_recvmsgs, nl_send_auto, nl_recvmsgs_default 64 | from libnl.nl80211 import nl80211 65 | from libnl.nl80211.helpers import parse_bss 66 | from libnl.nl80211.iw_scan import bss_policy 67 | from libnl.socket_ import ( 68 | nl_socket_add_membership, nl_socket_alloc, nl_socket_drop_membership, 69 | nl_socket_modify_cb 70 | ) 71 | from libnl.handlers import NL_CB_CUSTOM, NL_CB_VALID, NL_SKIP 72 | 73 | logger = logging.getLogger(__name__) 74 | 75 | 76 | class Scanner(object): 77 | 78 | def __init__(self, interface_name=None, scan=True): 79 | super().__init__() 80 | logger.debug( 81 | 'Initializing Scanner for interface: %s', 82 | interface_name 83 | ) 84 | self.interface_name = interface_name 85 | self._scan = scan 86 | self.iface_data = {} 87 | 88 | self._nl_sock = None 89 | 90 | # Get all interfaces of this machine 91 | self.if_idx = None 92 | self.iface_names = self.list_all_interfaces() 93 | self.bssid = None 94 | 95 | def set_interface(self, interface_name): 96 | for idx in self.iface_data: 97 | if self.iface_data[idx]['name'] == interface_name: 98 | self.if_idx = idx 99 | self.interface_name = interface_name 100 | break 101 | if self.if_idx is None: 102 | logger.error( 103 | "Device %s is not a valid interface, use one of %s", 104 | interface_name, self.iface_names 105 | ) 106 | exit(1) 107 | 108 | def list_all_interfaces(self): 109 | self.update_iface_details(nl80211.NL80211_CMD_GET_INTERFACE) 110 | iface_names = [] 111 | for idx in self.iface_data: 112 | if 'name' in self.iface_data[idx]: 113 | iface_names.append(self.iface_data[idx]['name']) 114 | return iface_names 115 | 116 | def _error_handler(self, _, err, arg): 117 | """Update the mutable integer `arg` with the error code.""" 118 | arg.value = err.error 119 | return libnl.handlers.NL_STOP 120 | 121 | def _ack_handler(self, _, arg): 122 | """Update the mutable integer `arg` with 0 as an acknowledgement.""" 123 | arg.value = 0 124 | return libnl.handlers.NL_STOP 125 | 126 | def _callback_trigger(self, msg, arg): 127 | # Called when the kernel is done scanning. Only signals if it was 128 | # successful or if it failed. No other data. 129 | # 130 | # Positional arguments: 131 | # msg -- nl_msg class instance containing the data sent by the kernel. 132 | # arg -- mutable integer (ctypes.c_int()) to update with results. 133 | # 134 | # Returns: 135 | # An integer, value of NL_SKIP. It tells libnl to stop calling other 136 | # callbacks for this message and proceed with processing the next kernel 137 | # message. 138 | gnlh = genlmsghdr(nlmsg_data(nlmsg_hdr(msg))) 139 | if gnlh.cmd == nl80211.NL80211_CMD_SCAN_ABORTED: 140 | arg.value = 1 # The scan was aborted for some reason. 141 | elif gnlh.cmd == nl80211.NL80211_CMD_NEW_SCAN_RESULTS: 142 | # The scan completed successfully. `callback_dump` will collect 143 | # the results later. 144 | arg.value = 0 145 | return libnl.handlers.NL_SKIP 146 | 147 | def _callback_dump(self, msg, results): 148 | # Here is where SSIDs and their data is decoded from the binary data 149 | # sent by the kernel. This function is called once per SSID. Everything 150 | # in `msg` pertains to just one SSID. 151 | # 152 | # Positional arguments: 153 | # msg -- nl_msg class instance containing the data sent by the kernel. 154 | # results -- dictionary to populate with parsed data. 155 | bss = dict() # To be filled by nla_parse_nested(). 156 | 157 | # First we must parse incoming data into manageable chunks and check 158 | # for errors. 159 | gnlh = genlmsghdr(nlmsg_data(nlmsg_hdr(msg))) 160 | tb = dict((i, None) for i in range(nl80211.NL80211_ATTR_MAX + 1)) 161 | nla_parse(tb, nl80211.NL80211_ATTR_MAX, genlmsg_attrdata( 162 | gnlh, 0), genlmsg_attrlen(gnlh, 0), None) 163 | if not tb[nl80211.NL80211_ATTR_BSS]: 164 | logger.warning('BSS info missing for an access point.') 165 | return libnl.handlers.NL_SKIP 166 | if nla_parse_nested(bss, nl80211.NL80211_BSS_MAX, 167 | tb[nl80211.NL80211_ATTR_BSS], bss_policy): 168 | logger.warning( 169 | 'Failed to parse nested attributes for an access point!') 170 | return libnl.handlers.NL_SKIP 171 | if not bss[nl80211.NL80211_BSS_BSSID]: 172 | logger.warning('No BSSID detected for an access point!') 173 | return libnl.handlers.NL_SKIP 174 | if not bss[nl80211.NL80211_BSS_INFORMATION_ELEMENTS]: 175 | logger.warning( 176 | 'No additional information available for an access point!') 177 | return libnl.handlers.NL_SKIP 178 | 179 | # Further parse and then store. Overwrite existing data for 180 | # BSSID if scan is run multiple times. 181 | bss_parsed = parse_bss(bss) 182 | results[bss_parsed['bssid']] = bss_parsed 183 | return libnl.handlers.NL_SKIP 184 | 185 | def _do_scan_trigger(self, if_index, driver_id, mcid): 186 | # Issue a scan request to the kernel and wait for it to reply with a 187 | # signal. 188 | # 189 | # This function issues NL80211_CMD_TRIGGER_SCAN which requires root 190 | # privileges. The way NL80211 works is first you issue 191 | # NL80211_CMD_TRIGGER_SCAN and wait for the kernel to signal that the 192 | # scan is done. When that signal occurs, data is not yet available. The 193 | # signal tells us if the scan was aborted or if it was successful (if 194 | # new scan results are waiting). This function handles that simple 195 | # signal. May exit the program (sys.exit()) if a fatal error occurs. 196 | # 197 | # Positional arguments: 198 | # self._nl_sock -- nl_sock class instance (from nl_socket_alloc()). 199 | # if_index -- interface index (integer). 200 | # driver_id -- nl80211 driver ID from genl_ctrl_resolve() (integer). 201 | # mcid -- nl80211 scanning group ID from genl_ctrl_resolve_grp() 202 | # (integer). 203 | # 204 | # Returns: 205 | # 0 on success or a negative error code. 206 | 207 | # First get the "scan" membership group ID and join the socket to the 208 | # group. 209 | logger.debug('Joining group %d.', mcid) 210 | # Listen for results of scan requests (aborted or new results). 211 | ret = nl_socket_add_membership(self._nl_sock, mcid) 212 | if ret < 0: 213 | return ret 214 | 215 | # Build the message to be sent to the kernel. 216 | msg = nlmsg_alloc() 217 | # Setup which command to run. 218 | genlmsg_put(msg, 0, 0, driver_id, 0, 0, 219 | nl80211.NL80211_CMD_TRIGGER_SCAN, 0) 220 | # Setup which interface to use. 221 | nla_put_u32(msg, nl80211.NL80211_ATTR_IFINDEX, if_index) 222 | ssids_to_scan = nlmsg_alloc() 223 | nla_put(ssids_to_scan, 1, 0, b'') # Scan all SSIDs. 224 | # Setup what kind of scan to perform. 225 | nla_put_nested(msg, nl80211.NL80211_ATTR_SCAN_SSIDS, ssids_to_scan) 226 | 227 | # Setup the callbacks to be used for triggering the scan only. 228 | # Used as a mutable integer to be updated by the callback function. 229 | # Signals end of messages. 230 | err = ctypes.c_int(1) 231 | # Signals if the scan was successful (new results) or aborted, or not 232 | # started. 233 | results = ctypes.c_int(-1) 234 | cb = libnl.handlers.nl_cb_alloc(libnl.handlers.NL_CB_DEFAULT) 235 | libnl.handlers.nl_cb_set( 236 | cb, libnl.handlers.NL_CB_VALID, libnl.handlers.NL_CB_CUSTOM, 237 | self._callback_trigger, results 238 | ) 239 | libnl.handlers.nl_cb_err( 240 | cb, libnl.handlers.NL_CB_CUSTOM, self._error_handler, err) 241 | libnl.handlers.nl_cb_set( 242 | cb, libnl.handlers.NL_CB_ACK, libnl.handlers.NL_CB_CUSTOM, 243 | self._ack_handler, err 244 | ) 245 | libnl.handlers.nl_cb_set( 246 | cb, libnl.handlers.NL_CB_SEQ_CHECK, libnl.handlers.NL_CB_CUSTOM, 247 | lambda *_: libnl.handlers.NL_OK, None 248 | ) # Ignore sequence checking. 249 | 250 | # Now we send the message to the kernel, and retrieve the 251 | # acknowledgement. The kernel takes a few seconds to finish scanning for 252 | # access points. 253 | logger.debug('Sending NL80211_CMD_TRIGGER_SCAN...') 254 | ret = nl_send_auto(self._nl_sock, msg) 255 | if ret < 0: 256 | return ret 257 | while err.value > 0: 258 | logger.debug( 259 | 'Retrieving NL80211_CMD_TRIGGER_SCAN acknowledgement...') 260 | ret = nl_recvmsgs(self._nl_sock, cb) 261 | if ret < 0: 262 | return ret 263 | if err.value < 0: 264 | logger.warning('Unknown error {0} ({1})'.format( 265 | err.value, errmsg[abs(err.value)])) 266 | 267 | # Block until the kernel is done scanning or aborted the scan. 268 | while results.value < 0: 269 | logger.debug( 270 | 'Retrieving NL80211_CMD_TRIGGER_SCAN final response...') 271 | ret = nl_recvmsgs(self._nl_sock, cb) 272 | if ret < 0: 273 | return ret 274 | if results.value > 0: 275 | logger.warning('The kernel aborted the scan.') 276 | 277 | # Done, cleaning up. 278 | logger.debug('Leaving group %d.', mcid) 279 | # No longer need to receive multicast messages. 280 | return nl_socket_drop_membership(self._nl_sock, mcid) 281 | 282 | def _do_scan_results(self, if_index, driver_id, results): 283 | # Retrieve the results of a successful scan (SSIDs and data about them). 284 | # This function does not require root privileges. It eventually calls a 285 | # callback that actually decodes data about SSIDs but this function 286 | # kicks that off. May exit the program (sys.exit()) if a fatal error 287 | # occurs. 288 | # 289 | # Positional arguments: 290 | # self._nl_sock -- nl_sock class instance (from nl_socket_alloc()). 291 | # if_index -- interface index (integer). 292 | # driver_id -- nl80211 driver ID from genl_ctrl_resolve() (integer). 293 | # results -- dictionary to populate with results. Keys are BSSIDs (MAC 294 | # addresses) and values are dicts of data. 295 | # Returns: 296 | # 0 on success or a negative error code. 297 | 298 | msg = nlmsg_alloc() 299 | genlmsg_put(msg, 0, 0, driver_id, 0, NLM_F_DUMP, 300 | nl80211.NL80211_CMD_GET_SCAN, 0) 301 | nla_put_u32(msg, nl80211.NL80211_ATTR_IFINDEX, if_index) 302 | cb = libnl.handlers.nl_cb_alloc(libnl.handlers.NL_CB_DEFAULT) 303 | libnl.handlers.nl_cb_set( 304 | cb, libnl.handlers.NL_CB_VALID, libnl.handlers.NL_CB_CUSTOM, 305 | self._callback_dump, results) 306 | logger.debug('Sending NL80211_CMD_GET_SCAN...') 307 | ret = nl_send_auto(self._nl_sock, msg) 308 | if ret >= 0: 309 | logger.debug('Retrieving NL80211_CMD_GET_SCAN response...') 310 | ret = nl_recvmsgs(self._nl_sock, cb) 311 | return ret 312 | 313 | def scan_all_access_points(self): 314 | # Scan for access points within reach 315 | 316 | # First get the wireless interface index. 317 | pack = struct.pack('16sI', self.interface_name.encode('ascii'), 0) 318 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 319 | try: 320 | info = struct.unpack('16sI', fcntl.ioctl( 321 | sock.fileno(), 0x8933, pack)) 322 | except OSError: 323 | return logger.warning( 324 | 'Wireless interface {0}\ 325 | does not exist.'.format(self.interface_name)) 326 | finally: 327 | sock.close() 328 | if_index = int(info[1]) 329 | 330 | # Next open a socket to the kernel and bind to it. Same one used for 331 | # sending and receiving. 332 | self._nl_sock = nl_socket_alloc() # Creates an `nl_sock` instance. 333 | genl_connect(self._nl_sock) # Create file descriptor and bind socket. 334 | logger.debug('Finding the nl80211 driver ID...') 335 | driver_id = genl_ctrl_resolve(self._nl_sock, b'nl80211') 336 | logger.debug('Finding the nl80211 scanning group ID...') 337 | mcid = genl_ctrl_resolve_grp(self._nl_sock, b'nl80211', b'scan') 338 | 339 | # Scan for access points 1 or more (if requested) times. 340 | results = dict() 341 | for i in range(2, -1, -1): # Three tries on errors. 342 | ret = self._do_scan_trigger(if_index, driver_id, mcid) 343 | if ret < 0: 344 | logger.debug('do_scan_trigger() returned {0},' 345 | 'retrying in 5 seconds({1}).'.format(ret, i)) 346 | time.sleep(5) 347 | ret = self._do_scan_results(if_index, driver_id, results) 348 | if ret < 0: 349 | logger.debug('do_scan_results() returned {0},' 350 | 'retrying in 5 seconds ({1}).'.format(ret, i)) 351 | time.sleep(5) 352 | continue 353 | break 354 | if not results: 355 | logger.debug('No access points detected.') 356 | return [] 357 | 358 | logger.debug('Found {0} access points:'.format(len(results))) 359 | 360 | # Convert timedelta to integer to avoid 361 | # TypeError: Object of type timedelta is not JSON serializable 362 | for ap in results: 363 | for prop in results[ap]: 364 | if isinstance(results[ap][prop], datetime.timedelta): 365 | results[ap][prop] = int( 366 | results[ap][prop].microseconds)/1000 367 | 368 | return results 369 | 370 | def _iface_callback(self, msg, _): 371 | # Callback function called by libnl upon receiving messages from the 372 | # kernel. 373 | # 374 | # Positional arguments: 375 | # msg -- nl_msg class instance containing the data sent by the kernel. 376 | # 377 | # Returns: 378 | # An integer, value of NL_SKIP. It tells libnl to stop calling other 379 | # callbacks for this message and proceed with processing the next kernel 380 | # message. 381 | # First convert `msg` into something more manageable. 382 | gnlh = genlmsghdr(nlmsg_data(nlmsg_hdr(msg))) 383 | 384 | # Partially parse the raw binary data and place them in the `tb` 385 | # dictionary. Need to populate dict with all possible keys. 386 | tb = dict((i, None) for i in range(nl80211.NL80211_ATTR_MAX + 1)) 387 | nla_parse(tb, nl80211.NL80211_ATTR_MAX, genlmsg_attrdata( 388 | gnlh, 0), genlmsg_attrlen(gnlh, 0), None) 389 | 390 | # Now it's time to grab the data, we start with the interface index as 391 | # universal identifier 392 | if tb[nl80211.NL80211_ATTR_IFINDEX]: 393 | if_index = nla_get_u32(tb[nl80211.NL80211_ATTR_IFINDEX]) 394 | else: 395 | return NL_SKIP 396 | 397 | # Create new interface dict if this interface is not yet known 398 | if if_index in self.iface_data: 399 | iface_data = self.iface_data[if_index] 400 | else: 401 | iface_data = {} 402 | 403 | if tb[nl80211.NL80211_ATTR_IFNAME]: 404 | iface_data['name'] = nla_get_string( 405 | tb[nl80211.NL80211_ATTR_IFNAME]).decode('ascii') 406 | 407 | if tb[nl80211.NL80211_ATTR_IFTYPE]: 408 | iftype = nla_get_u32(tb[nl80211.NL80211_ATTR_IFTYPE]) 409 | 410 | if iftype == nl80211.NL80211_IFTYPE_UNSPECIFIED: 411 | typestr = 'UNSPECIFIED' 412 | elif iftype == nl80211.NL80211_IFTYPE_ADHOC: 413 | typestr = 'ADHOC' 414 | elif iftype == nl80211.NL80211_IFTYPE_STATION: 415 | typestr = 'STATION' 416 | elif iftype == nl80211.NL80211_IFTYPE_AP: 417 | typestr = 'AP' 418 | elif iftype == nl80211.NL80211_IFTYPE_AP_VLAN: 419 | typestr = 'AP_VLAN' 420 | elif iftype == nl80211.NL80211_IFTYPE_WDS: 421 | typestr = 'WDS' 422 | elif iftype == nl80211.NL80211_IFTYPE_MONITOR: 423 | typestr = 'MONITOR' 424 | elif iftype == nl80211.NL80211_IFTYPE_MESH_POINT: 425 | typestr = 'MESH_POINT' 426 | elif iftype == nl80211.NL80211_IFTYPE_P2P_CLIENT: 427 | typestr = 'P2P_CLIENT' 428 | elif iftype == nl80211.NL80211_IFTYPE_P2P_GO: 429 | typestr = 'P2P_GO' 430 | elif iftype == nl80211.NL80211_IFTYPE_P2P_DEVICE: 431 | typestr = 'P2P_DEVICE' 432 | 433 | iface_data['type'] = typestr 434 | 435 | if tb[nl80211.NL80211_ATTR_WIPHY]: 436 | wiphy_num = nla_get_u32(tb[nl80211.NL80211_ATTR_WIPHY]) 437 | iface_data['wiphy'] = 'phy#{0}'.format(wiphy_num) 438 | 439 | if tb[nl80211.NL80211_ATTR_MAC]: 440 | mac_raw = nla_data(tb[nl80211.NL80211_ATTR_MAC])[:6] 441 | mac_address = ':'.join(format(x, '02x') for x in mac_raw) 442 | iface_data['mac'] = mac_address 443 | if ( 444 | gnlh.cmd == nl80211.NL80211_CMD_NEW_STATION and 445 | if_index == self.if_idx 446 | ): 447 | # This is the BSSID that we're currently associated to 448 | self.bssid = mac_address 449 | 450 | if tb[nl80211.NL80211_ATTR_GENERATION]: 451 | generation = nla_get_u32(tb[nl80211.NL80211_ATTR_GENERATION]) 452 | # Do not overwrite the generation for excessively large values 453 | if generation < 100: 454 | iface_data['generation'] = generation 455 | 456 | if tb[nl80211.NL80211_ATTR_WIPHY_TX_POWER_LEVEL]: 457 | iface_data['tx_power'] = nla_get_u32( 458 | tb[nl80211.NL80211_ATTR_WIPHY_TX_POWER_LEVEL])/100 # mW 459 | 460 | if tb[nl80211.NL80211_ATTR_CHANNEL_WIDTH]: 461 | iface_data['ch_width'] = nla_get_u32( 462 | tb[nl80211.NL80211_ATTR_CHANNEL_WIDTH]) 463 | 464 | if tb[nl80211.NL80211_ATTR_CENTER_FREQ1]: 465 | iface_data['frequency'] = nla_get_u32( 466 | tb[nl80211.NL80211_ATTR_CENTER_FREQ1]) 467 | 468 | # Station infos 469 | if tb[nl80211.NL80211_ATTR_STA_INFO]: 470 | # Need to unpack the data 471 | sinfo = dict((i, None) 472 | for i in range(nl80211.NL80211_STA_INFO_MAX)) 473 | rinfo = dict((i, None) 474 | for i in range(nl80211.NL80211_STA_INFO_TX_BITRATE)) 475 | 476 | # Extract data 477 | nla_parse_nested(sinfo, nl80211.NL80211_STA_INFO_MAX, 478 | tb[nl80211.NL80211_ATTR_STA_INFO], None) 479 | 480 | # Extract info about signal strength (= quality) 481 | if sinfo[nl80211.NL80211_STA_INFO_SIGNAL]: 482 | iface_data['signal'] = 100 + \ 483 | nla_get_u8(sinfo[nl80211.NL80211_STA_INFO_SIGNAL]) 484 | # Compute quality (formula found in iwinfo_nl80211.c and largely 485 | # simplified) 486 | iface_data['quality'] = iface_data['signal'] + 110 487 | iface_data['quality_max'] = 70 488 | 489 | # Extract info about negotiated bitrate 490 | if sinfo[nl80211.NL80211_STA_INFO_TX_BITRATE]: 491 | nla_parse_nested(rinfo, nl80211.NL80211_RATE_INFO_MAX, 492 | sinfo[nl80211.NL80211_STA_INFO_TX_BITRATE], 493 | None) 494 | if rinfo[nl80211.NL80211_RATE_INFO_BITRATE]: 495 | iface_data['bitrate'] = nla_get_u16( 496 | rinfo[nl80211.NL80211_RATE_INFO_BITRATE])/10 497 | 498 | # BSS info 499 | if tb[nl80211.NL80211_ATTR_BSS]: 500 | # Need to unpack the data 501 | binfo = dict((i, None) for i in range(nl80211.NL80211_BSS_MAX)) 502 | nla_parse_nested(binfo, nl80211.NL80211_BSS_MAX, 503 | tb[nl80211.NL80211_ATTR_BSS], None) 504 | 505 | # Parse BSS section (if complete) 506 | try: 507 | bss = parse_bss(binfo) 508 | 509 | # Remove duplicated information blocks 510 | if 'beacon_ies' in bss: 511 | del bss['beacon_ies'] 512 | if 'information_elements' in bss: 513 | del bss['information_elements'] 514 | if 'supported_rates' in bss: 515 | del bss['supported_rates'] 516 | 517 | # Convert timedelta objects for later JSON encoding 518 | for prop in bss: 519 | if isinstance(bss[prop], datetime.timedelta): 520 | bss[prop] = int(bss[prop].microseconds)/1000 521 | 522 | # Append BSS data to general object 523 | iface_data = {**iface_data, **bss} 524 | except Exception as e: 525 | logger.warning("Obtaining BSS data failed: {}".format(e)) 526 | pass 527 | 528 | # Append data to global structure 529 | self.iface_data[if_index] = iface_data 530 | return NL_SKIP 531 | 532 | def update_iface_details(self, cmd): 533 | # Send a command specified by CMD to the kernel and attach a callback to 534 | # process the returned values into our own datastructure 535 | self._nl_sock = nl_socket_alloc() # Creates an `nl_sock` instance. 536 | # Create file descriptor and bind socket. 537 | ret = genl_connect(self._nl_sock) 538 | if ret < 0: 539 | reason = errmsg[abs(ret)] 540 | logger.error( 541 | 'genl_connect() returned {0} ({1})'.format(ret, reason)) 542 | return {} 543 | 544 | # Now get the nl80211 driver ID. Handle errors here. 545 | # Find the nl80211 driver ID. 546 | driver_id = genl_ctrl_resolve(self._nl_sock, b'nl80211') 547 | if driver_id < 0: 548 | reason = errmsg[abs(driver_id)] 549 | logger.error( 550 | 'genl_ctrl_resolve() returned {0} ({1})'.format(driver_id, 551 | reason)) 552 | return {} 553 | 554 | # Setup the Generic Netlink message. 555 | msg = nlmsg_alloc() # Allocate a message. 556 | if self.if_idx is None: 557 | # Ask kernel to send info for all wireless interfaces. 558 | genlmsg_put(msg, 0, 0, driver_id, 0, NLM_F_DUMP, 559 | nl80211.NL80211_CMD_GET_INTERFACE, 0) 560 | else: 561 | genlmsg_put(msg, 0, 0, driver_id, 0, NLM_F_DUMP, cmd, 0) 562 | # This is the interface we care about. 563 | nla_put_u32(msg, nl80211.NL80211_ATTR_IFINDEX, self.if_idx) 564 | 565 | # Add the callback function to the self._nl_sock. 566 | nl_socket_modify_cb(self._nl_sock, NL_CB_VALID, 567 | NL_CB_CUSTOM, self._iface_callback, False) 568 | 569 | # Now send the message to the kernel, and get its response, 570 | # automatically calling the callback. 571 | ret = nl_send_auto(self._nl_sock, msg) 572 | if ret < 0: 573 | reason = errmsg[abs(ret)] 574 | logger.error( 575 | 'nl_send_auto() returned {0} ({1})'.format(ret, reason)) 576 | return {} 577 | logger.debug('Sent {0} bytes to the kernel.'.format(ret)) 578 | # Blocks until the kernel replies. Usually it's instant. 579 | ret = nl_recvmsgs_default(self._nl_sock) 580 | if ret < 0: 581 | reason = errmsg[abs(ret)] 582 | logger.error( 583 | 'nl_recvmsgs_default() returned {0} ({1})'.format(ret, reason)) 584 | return {} 585 | 586 | def get_iface_data(self, update=False): 587 | if update: 588 | logger.debug("Updating WiFi interface data ...") 589 | self.update_iface_details(nl80211.NL80211_CMD_GET_STATION) 590 | self.update_iface_details(nl80211.NL80211_CMD_GET_SCAN) 591 | return self.iface_data[self.if_idx] 592 | 593 | def get_current_bssid(self): 594 | """ 595 | Returns the current BSSID MAC address as a string, or None if not 596 | currently associated. 597 | """ 598 | assert self.if_idx is not None 599 | self.bssid = None 600 | self.update_iface_details(nl80211.NL80211_CMD_GET_SCAN) 601 | self.update_iface_details(nl80211.NL80211_CMD_GET_STATION) 602 | return self.bssid 603 | -------------------------------------------------------------------------------- /wifi_survey_heatmap/scancli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | The latest version of this package is available at: 4 | <http://github.com/jantman/wifi-survey-heatmap> 5 | 6 | ################################################################################## 7 | Copyright 2018 Jason Antman <jason@jasonantman.com> <http://www.jasonantman.com> 8 | 9 | This file is part of wifi-survey-heatmap, also known as wifi-survey-heatmap. 10 | 11 | wifi-survey-heatmap is free software: you can redistribute it and/or modify 12 | it under the terms of the GNU Affero General Public License as published by 13 | the Free Software Foundation, either version 3 of the License, or 14 | (at your option) any later version. 15 | 16 | wifi-survey-heatmap is distributed in the hope that it will be useful, 17 | but WITHOUT ANY WARRANTY; without even the implied warranty of 18 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 19 | GNU Affero General Public License for more details. 20 | 21 | You should have received a copy of the GNU Affero General Public License 22 | along with wifi-survey-heatmap. If not, see <http://www.gnu.org/licenses/>. 23 | 24 | The Copyright and Authors attributions contained herein may not be removed or 25 | otherwise altered, except to add the Author attribution of a contributor to 26 | this work. (Additional Terms pursuant to Section 7b of the AGPL v3) 27 | ################################################################################## 28 | While not legally required, I sincerely request that anyone who finds 29 | bugs please submit them at <https://github.com/jantman/wifi-survey-heatmap> or 30 | to me via email, and that you send any contributions or improvements 31 | either as a pull request on GitHub, or to me via email. 32 | ################################################################################## 33 | 34 | AUTHORS: 35 | Jason Antman <jason@jasonantman.com> <http://www.jasonantman.com> 36 | ################################################################################## 37 | """ 38 | 39 | import sys 40 | import argparse 41 | import logging 42 | import os 43 | 44 | from wifi_survey_heatmap.collector import Collector 45 | 46 | FORMAT = "[%(asctime)s %(levelname)s] %(message)s" 47 | logging.basicConfig(level=logging.WARNING, format=FORMAT) 48 | logger = logging.getLogger() 49 | 50 | 51 | class CliWrapper(object): 52 | 53 | def run(self, ifname, server): 54 | if os.geteuid() != 0: 55 | raise RuntimeError('ERROR: This script must be run as root/sudo.') 56 | c = Collector(ifname, server) 57 | print(c.run()) 58 | 59 | 60 | def parse_args(argv): 61 | """ 62 | parse arguments/options 63 | 64 | this uses the new argparse module instead of optparse 65 | see: <https://docs.python.org/2/library/argparse.html> 66 | """ 67 | p = argparse.ArgumentParser(description='wifi scan CLI') 68 | p.add_argument('-v', '--verbose', dest='verbose', action='count', default=0, 69 | help='verbose output. specify twice for debug-level output.') 70 | p.add_argument('INTERFACE', type=str, help='Wireless interface name') 71 | p.add_argument('SERVER', type=str, help='iperf3 server IP or hostname') 72 | args = p.parse_args(argv) 73 | return args 74 | 75 | 76 | def set_log_info(): 77 | """set logger level to INFO""" 78 | set_log_level_format(logging.INFO, 79 | '%(asctime)s %(levelname)s:%(name)s:%(message)s') 80 | 81 | 82 | def set_log_debug(): 83 | """set logger level to DEBUG, and debug-level output format""" 84 | set_log_level_format( 85 | logging.DEBUG, 86 | "%(asctime)s [%(levelname)s %(filename)s:%(lineno)s - " 87 | "%(name)s.%(funcName)s() ] %(message)s" 88 | ) 89 | 90 | 91 | def set_log_level_format(level, format): 92 | """ 93 | Set logger level and format. 94 | 95 | :param level: logging level; see the :py:mod:`logging` constants. 96 | :type level: int 97 | :param format: logging formatter format string 98 | :type format: str 99 | """ 100 | formatter = logging.Formatter(fmt=format) 101 | logger.handlers[0].setFormatter(formatter) 102 | logger.setLevel(level) 103 | 104 | 105 | def main(): 106 | args = parse_args(sys.argv[1:]) 107 | 108 | # set logging level 109 | if args.verbose > 1: 110 | set_log_debug() 111 | elif args.verbose == 1: 112 | set_log_info() 113 | 114 | CliWrapper().run(args.INTERFACE, args.SERVER) 115 | 116 | 117 | if __name__ == "__main__": 118 | main() 119 | -------------------------------------------------------------------------------- /wifi_survey_heatmap/thresholds.py: -------------------------------------------------------------------------------- 1 | """ 2 | The latest version of this package is available at: 3 | <http://github.com/jantman/wifi-survey-heatmap> 4 | 5 | ################################################################################## 6 | Copyright 2017 Jason Antman <jason@jasonantman.com> <http://www.jasonantman.com> 7 | 8 | This file is part of wifi-survey-heatmap, also known as wifi-survey-heatmap. 9 | 10 | wifi-survey-heatmap is free software: you can redistribute it and/or modify 11 | it under the terms of the GNU Affero General Public License as published by 12 | the Free Software Foundation, either version 3 of the License, or 13 | (at your option) any later version. 14 | 15 | wifi-survey-heatmap is distributed in the hope that it will be useful, 16 | but WITHOUT ANY WARRANTY; without even the implied warranty of 17 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 | GNU Affero General Public License for more details. 19 | 20 | You should have received a copy of the GNU Affero General Public License 21 | along with wifi-survey-heatmap. If not, see <http://www.gnu.org/licenses/>. 22 | 23 | The Copyright and Authors attributions contained herein may not be removed or 24 | otherwise altered, except to add the Author attribution of a contributor to 25 | this work. (Additional Terms pursuant to Section 7b of the AGPL v3) 26 | ################################################################################## 27 | While not legally required, I sincerely request that anyone who finds 28 | bugs please submit them at <https://github.com/jantman/wifi-survey-heatmap> or 29 | to me via email, and that you send any contributions or improvements 30 | either as a pull request on GitHub, or to me via email. 31 | ################################################################################## 32 | 33 | AUTHORS: 34 | Jason Antman <jason@jasonantman.com> <http://www.jasonantman.com> 35 | ################################################################################## 36 | """ 37 | 38 | import sys 39 | import argparse 40 | import logging 41 | import json 42 | from collections import defaultdict 43 | 44 | from wifi_survey_heatmap.heatmap import HeatMapGenerator 45 | 46 | FORMAT = "[%(asctime)s %(levelname)s] %(message)s" 47 | logging.basicConfig(level=logging.WARNING, format=FORMAT) 48 | logger = logging.getLogger() 49 | 50 | 51 | class ThresholdGenerator(object): 52 | 53 | def generate(self, titles): 54 | res = defaultdict(dict) 55 | items = [ 56 | HeatMapGenerator( 57 | None, t, False, 'RdYlBu_r', None 58 | ).load_data() 59 | for t in titles 60 | ] 61 | for key in HeatMapGenerator.graphs.keys(): 62 | res[key]['min'] = min([ 63 | min(value for value in x[key] if value is not None) for x in items 64 | ]) 65 | res[key]['max'] = max([ 66 | max(value for value in x[key] if value is not None) for x in items 67 | ]) 68 | with open('thresholds.json', 'w') as fh: 69 | fh.write(json.dumps(res)) 70 | logger.info('Wrote: thresholds.json') 71 | 72 | 73 | def parse_args(argv): 74 | """ 75 | parse arguments/options 76 | 77 | this uses the new argparse module instead of optparse 78 | see: <https://docs.python.org/2/library/argparse.html> 79 | """ 80 | p = argparse.ArgumentParser( 81 | description='wifi survey heatmap threshold generator' 82 | ) 83 | p.add_argument('-v', '--verbose', dest='verbose', action='count', default=0, 84 | help='verbose output. specify twice for debug-level output.') 85 | p.add_argument( 86 | 'TITLE', type=str, help='Title for survey (and data filename)', 87 | nargs='+' 88 | ) 89 | args = p.parse_args(argv) 90 | return args 91 | 92 | 93 | def set_log_info(): 94 | """set logger level to INFO""" 95 | set_log_level_format(logging.INFO, 96 | '%(asctime)s %(levelname)s:%(name)s:%(message)s') 97 | 98 | 99 | def set_log_debug(): 100 | """set logger level to DEBUG, and debug-level output format""" 101 | set_log_level_format( 102 | logging.DEBUG, 103 | "%(asctime)s [%(levelname)s %(filename)s:%(lineno)s - " 104 | "%(name)s.%(funcName)s() ] %(message)s" 105 | ) 106 | 107 | 108 | def set_log_level_format(level, format): 109 | """ 110 | Set logger level and format. 111 | 112 | :param level: logging level; see the :py:mod:`logging` constants. 113 | :type level: int 114 | :param format: logging formatter format string 115 | :type format: str 116 | """ 117 | formatter = logging.Formatter(fmt=format) 118 | logger.handlers[0].setFormatter(formatter) 119 | logger.setLevel(level) 120 | 121 | 122 | def main(): 123 | args = parse_args(sys.argv[1:]) 124 | 125 | # set logging level 126 | if args.verbose > 1: 127 | set_log_debug() 128 | elif args.verbose == 1: 129 | set_log_info() 130 | 131 | ThresholdGenerator().generate(args.TITLE) 132 | 133 | 134 | if __name__ == '__main__': 135 | main() 136 | -------------------------------------------------------------------------------- /wifi_survey_heatmap/ui.py: -------------------------------------------------------------------------------- 1 | """ 2 | The latest version of this package is available at: 3 | <http://github.com/jantman/wifi-survey-heatmap> 4 | 5 | ################################################################################## 6 | Copyright 2017 Jason Antman <jason@jasonantman.com> <http://www.jasonantman.com> 7 | 8 | This file is part of wifi-survey-heatmap, also known as wifi-survey-heatmap. 9 | 10 | wifi-survey-heatmap is free software: you can redistribute it and/or modify 11 | it under the terms of the GNU Affero General Public License as published by 12 | the Free Software Foundation, either version 3 of the License, or 13 | (at your option) any later version. 14 | 15 | wifi-survey-heatmap is distributed in the hope that it will be useful, 16 | but WITHOUT ANY WARRANTY; without even the implied warranty of 17 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 | GNU Affero General Public License for more details. 19 | 20 | You should have received a copy of the GNU Affero General Public License 21 | along with wifi-survey-heatmap. If not, see <http://www.gnu.org/licenses/>. 22 | 23 | The Copyright and Authors attributions contained herein may not be removed or 24 | otherwise altered, except to add the Author attribution of a contributor to 25 | this work. (Additional Terms pursuant to Section 7b of the AGPL v3) 26 | ################################################################################## 27 | While not legally required, I sincerely request that anyone who finds 28 | bugs please submit them at <https://github.com/jantman/wifi-survey-heatmap> or 29 | to me via email, and that you send any contributions or improvements 30 | either as a pull request on GitHub, or to me via email. 31 | ################################################################################## 32 | 33 | AUTHORS: 34 | Jason Antman <jason@jasonantman.com> <http://www.jasonantman.com> 35 | ################################################################################## 36 | """ 37 | 38 | import sys 39 | import argparse 40 | import logging 41 | import wx 42 | import json 43 | import os 44 | import subprocess 45 | import threading 46 | from pubsub import pub 47 | 48 | from wifi_survey_heatmap.collector import Collector 49 | from wifi_survey_heatmap.libnl import Scanner 50 | 51 | FORMAT = "[%(asctime)s %(levelname)s] %(message)s" 52 | logging.basicConfig(level=logging.WARNING, format=FORMAT) 53 | logger = logging.getLogger() 54 | 55 | 56 | RESULT_FIELDS = [ 57 | 'error', 58 | 'time', 59 | 'timesecs', 60 | 'protocol', 61 | 'num_streams', 62 | 'blksize', 63 | 'omit', 64 | 'duration', 65 | 'sent_bytes', 66 | 'sent_bps', 67 | 'received_bytes', 68 | 'received_bps', 69 | 'sent_kbps', 70 | 'sent_Mbps', 71 | 'sent_kB_s', 72 | 'sent_MB_s', 73 | 'received_kbps', 74 | 'received_Mbps', 75 | 'received_kB_s', 76 | 'received_MB_s', 77 | 'retransmits', 78 | 'bytes', 79 | 'bps', 80 | 'jitter_ms', 81 | 'kbps', 82 | 'Mbps', 83 | 'kB_s', 84 | 'MB_s', 85 | 'packets', 86 | 'lost_packets', 87 | 'lost_percent', 88 | 'seconds' 89 | ] 90 | 91 | 92 | class SurveyPoint(object): 93 | 94 | def __init__(self, parent, x, y): 95 | self.parent = parent 96 | self.x = x 97 | self.y = y 98 | self.is_finished = False 99 | self.is_failed = False 100 | self.progress = 0 101 | self.dotSize = 20 102 | self.result = {} 103 | 104 | def set_result(self, res): 105 | self.result = res 106 | 107 | @property 108 | def as_dict(self): 109 | return { 110 | 'x': self.x, 111 | 'y': self.y, 112 | 'result': self.result, 113 | 'failed': self.is_failed 114 | } 115 | 116 | def set_is_failed(self): 117 | self.is_failed = True 118 | self.progress = 0 119 | 120 | def set_progress(self, value, total): 121 | self.progress = int(100*value/total) 122 | 123 | def set_is_finished(self): 124 | self.is_finished = True 125 | self.is_failed = False 126 | self.progress = 100 127 | 128 | def draw(self, dc, color=None): 129 | if color is None: 130 | color = 'green' 131 | if not self.is_finished: 132 | color = 'orange' 133 | if self.is_failed: 134 | color = 'red' 135 | dc.SetPen(wx.Pen(color, style=wx.TRANSPARENT)) 136 | dc.SetBrush(wx.Brush(color, wx.SOLID)) 137 | 138 | # Relative scaling 139 | x = self.x / self.parent.scale_x 140 | y = self.y / self.parent.scale_y 141 | 142 | # Draw circle 143 | dc.DrawCircle(int(x), int(y), self.dotSize) 144 | 145 | # Put progress label on top of the circle 146 | dc.DrawLabel( 147 | "{}%".format(self.progress), 148 | wx.Rect( 149 | int(x-self.dotSize/2), int(y-self.dotSize/2), 150 | int(self.dotSize), int(self.dotSize) 151 | ), 152 | wx.ALIGN_CENTER 153 | ) 154 | 155 | def erase(self, dc): 156 | """quicker than redrawing, since DC doesn't have persistence""" 157 | dc.SetPen(wx.Pen('white', style=wx.TRANSPARENT)) 158 | dc.SetBrush(wx.Brush('white', wx.SOLID)) 159 | # Relative scaling 160 | x = self.x / self.parent.scale_x 161 | y = self.y / self.parent.scale_y 162 | dc.DrawCircle(int(x), int(y), int(1.1*self.dotSize)) 163 | 164 | def includes_point(self, x, y): 165 | if ( 166 | self.x - 20 <= x <= self.x + 20 and 167 | self.y - 20 <= y <= self.y + 20 168 | ): 169 | return True 170 | return False 171 | 172 | 173 | class SafeEncoder(json.JSONEncoder): 174 | 175 | def default(self, obj): 176 | if isinstance(obj, type(b'')): 177 | return obj.decode() 178 | return json.JSONEncoder.default(self, obj) 179 | 180 | class WorkerThread(threading.Thread): 181 | def __init__(self, action): 182 | threading.Thread.__init__(self) 183 | self.setDaemon(1) 184 | self._action = action 185 | self._want_abort = False 186 | self.done = False 187 | self.start() 188 | 189 | def run(self): 190 | try: 191 | self._action(lambda: self._want_abort) 192 | finally: 193 | self.done = True 194 | 195 | def abort(self): 196 | self._want_abort=True 197 | 198 | class FloorplanPanel(wx.Panel): 199 | 200 | # UI thread only 201 | def __init__(self, parent): 202 | super(FloorplanPanel, self).__init__(parent) 203 | self.parent = parent 204 | self.ui_thread = threading.current_thread() 205 | self.img_path = parent.img_path 206 | self.Bind(wx.EVT_ERASE_BACKGROUND, self.OnEraseBackground) 207 | self.Bind(wx.EVT_LEFT_UP, self.onLeftUp) 208 | self.Bind(wx.EVT_LEFT_DOWN, self.onLeftDown) 209 | self.Bind(wx.EVT_MOTION, self.onMotion) 210 | self.Bind(wx.EVT_RIGHT_UP, self.onRightClick) 211 | self.Bind(wx.EVT_PAINT, self.on_paint) 212 | pub.subscribe(self.setStatus, "status") 213 | pub.subscribe(self.warn, "warn") 214 | pub.subscribe(self.Refresh, "refresh") 215 | self.survey_points = [] 216 | self._moving_point = None 217 | self._moving_x = None 218 | self._moving_y = None 219 | self.scale_x = 1.0 220 | self.scale_y = 1.0 221 | self.data_filename = '%s.json' % self.parent.survey_title 222 | if os.path.exists(self.data_filename): 223 | self._load_file(self.data_filename) 224 | self._duration = self.parent.duration 225 | self.collector = Collector( 226 | self.parent.server, self._duration, self.parent.scanner) 227 | self.parent.SetStatusText("Ready.") 228 | self.current_worker = None 229 | 230 | # UI thread only 231 | def _load_file(self, fpath): 232 | with open(fpath, 'r') as fh: 233 | raw = fh.read() 234 | data = json.loads(raw) 235 | if 'survey_points' not in data: 236 | logger.error('Trying to load incompatible JSON file') 237 | exit(1) 238 | for point in data['survey_points']: 239 | p = SurveyPoint(self, point['x'], point['y']) 240 | p.set_result(point['result']) 241 | p.set_is_finished() 242 | self.survey_points.append(p) 243 | 244 | # UI thread only 245 | def OnEraseBackground(self, evt): 246 | """Add a picture to the background""" 247 | dc = evt.GetDC() 248 | if not dc: 249 | dc = wx.ClientDC(self) 250 | rect = self.GetUpdateRegion().GetBox() 251 | dc.SetClippingRect(rect) 252 | dc.Clear() 253 | 254 | # Get window size 255 | W, H = self.GetSize() 256 | 257 | # Load floorplan 258 | bmp = wx.Bitmap(self.img_path) 259 | image = wx.Bitmap.ConvertToImage(bmp) 260 | 261 | # Store scaling factors for pixel corrections 262 | self.scale_x = image.GetWidth() / W 263 | self.scale_y = image.GetHeight() / H 264 | 265 | # Scale image to window size 266 | logger.debug("Scaling image to {} x {}".format(W, H)) 267 | image = image.Scale(W, H, wx.IMAGE_QUALITY_HIGH) 268 | 269 | # Draw image 270 | scaled_bmp = wx.Bitmap(image) 271 | dc.DrawBitmap(scaled_bmp, 0, 0) 272 | 273 | # Any Thread 274 | def setStatus(self, text): 275 | if threading.current_thread() is self.ui_thread: 276 | self.parent.SetStatusText(text) 277 | self.Refresh() 278 | else: 279 | self.onUiThread("status", text=text) 280 | 281 | # On non-UI thread 282 | def onUiThread(self, methodName, **args): 283 | wx.CallAfter(pub.sendMessage, methodName, **args) 284 | 285 | # Get X and Y coordinated scaled to ABSOLUTE coordinates of the floorplan 286 | # UI thread only 287 | def get_xy(self, event): 288 | X, Y = event.GetPosition() 289 | W, H = self.GetSize() 290 | x = int(X * self.scale_x) 291 | y = int(Y * self.scale_y) 292 | return [x, y] 293 | 294 | # UI thread only 295 | def onRightClick(self, event): 296 | x, y = self.get_xy(event) 297 | point = None 298 | for p in self.survey_points: 299 | # important to iterate the whole list, so we find the most recent 300 | if p.includes_point(x, y): 301 | point = p 302 | if point is None: 303 | self.setStatus( 304 | f"No survey point found at ({x}, {y})" 305 | ) 306 | return 307 | # ok, we have a point to remove 308 | point.draw(wx.ClientDC(self), color='blue') 309 | self.Refresh() 310 | res = self.YesNo(f'Remove point at ({x}, {y}) shown in blue?') 311 | if not res: 312 | self.setStatus('Not removing point.') 313 | return 314 | self.survey_points.remove(point) 315 | self.setStatus(f'Removed point at ({x}, {y})') 316 | self._write_json() 317 | 318 | # UI thread only 319 | def onLeftDown(self, event): 320 | x, y = self.get_xy(event) 321 | point = None 322 | for p in self.survey_points: 323 | # important to iterate the whole list, so we find the most recent 324 | if p.includes_point(x, y): 325 | point = p 326 | if point is None: 327 | self.setStatus( 328 | f"No survey point found at ({x}, {y})" 329 | ) 330 | return 331 | self._moving_point = point 332 | self._moving_x = point.x 333 | self._moving_y = point.y 334 | point.draw(wx.ClientDC(self), color='lightblue') 335 | 336 | # UI thread only 337 | def onLeftUp(self, event): 338 | x, y = pos = self.get_xy(event) 339 | if self._moving_point is None: 340 | self._do_measurement(pos) 341 | return 342 | oldx = self._moving_point.x 343 | oldy = self._moving_point.y 344 | self._moving_point.x = x 345 | self._moving_point.y = y 346 | self._moving_point.draw(wx.ClientDC(self), color='lightblue') 347 | self.Refresh() 348 | res = self.YesNo( 349 | f'Move point from ({oldx}, {oldy}) to ({x}, {y})?' 350 | ) 351 | if not res: 352 | self._moving_point.x = self._moving_x 353 | self._moving_point.y = self._moving_y 354 | self._moving_point = None 355 | self._moving_x = None 356 | self._moving_y = None 357 | self.Refresh() 358 | self._write_json() 359 | 360 | # UI thread only 361 | def onMotion(self, event): 362 | if self._moving_point is None: 363 | return 364 | x, y = pos = self.get_xy(event) 365 | dc = wx.ClientDC(self) 366 | self._moving_point.erase(dc) 367 | self._moving_point.x = x 368 | self._moving_point.y = y 369 | self._moving_point.draw(dc, color='lightblue') 370 | 371 | # Background thread only 372 | def _check_bssid(self): 373 | # Return early if BSSID is not to be verified 374 | if self.parent.bssid is None: 375 | return True 376 | # Get BSSID from link 377 | bssid = self.collector.scanner.get_current_bssid() 378 | # Compare BSSID, exit early on match 379 | if bssid == self.parent.bssid: 380 | return True 381 | # Error logging 382 | logger.error( 383 | 'Expected BSSID %s but found BSSID %s from kernel', 384 | self.parent.bssid, bssid 385 | ) 386 | msg = f'ERROR: Expected BSSID {self.parent.bssid} but found ' \ 387 | f'BSSID {bssid}' 388 | self.setStatus(msg) 389 | self.onUiThread("warn", message=msg) 390 | return False 391 | 392 | # Any thread 393 | def _abort(self, reason): 394 | self.survey_points[-1].set_is_failed() 395 | self.setStatus('Aborted: {}'.format(reason)) 396 | 397 | # UI thread only 398 | def _do_measurement(self, pos): 399 | if self.current_worker and self.current_worker.done == False: 400 | return 401 | # Add new survey point 402 | self.survey_points.append(SurveyPoint(self, pos[0], pos[1])) 403 | # Delete failed survey points 404 | self.survey_points = [p for p in self.survey_points if not p.is_failed] 405 | self.setStatus('Starting survey...') 406 | 407 | # Check if we are connected to an AP, all the 408 | # rest doesn't any sense otherwise 409 | if not self.collector.check_associated(): 410 | self._abort("Not connected to an access point") 411 | return 412 | # Check BSSID 413 | if not self._check_bssid(): 414 | self._abort("BSSID check failed") 415 | return 416 | self.current_worker = WorkerThread(self._do_work) 417 | 418 | # Background thread only 419 | def _do_work(self, is_cancelled): 420 | res = {} 421 | count = 0 422 | # Number of steps in total (for the progress computation) 423 | steps = 5 424 | # Skip iperf test if empty server string was given 425 | if self.collector._iperf_server is not None: 426 | for protoname, udp in {'tcp': False, 'udp': True}.items(): 427 | for suffix, reverse in {'': False, '-reverse': True}.items(): 428 | # Update progress mark 429 | self.survey_points[-1].set_progress(count, steps) 430 | self.onUiThread("refresh") 431 | count += 1 432 | 433 | # Check if we're still connected to the same AP 434 | if not self._check_bssid(): 435 | self._abort("BSSID check failed") 436 | return 437 | 438 | # Start iperf test 439 | tmp = self.run_iperf(count, udp, reverse) 440 | if tmp is None: 441 | # bail out; abort this survey point 442 | self._abort("iperf test failed") 443 | return 444 | # else success 445 | res['%s%s' % (protoname, suffix)] = { 446 | x: getattr(tmp, x, None) for x in RESULT_FIELDS 447 | } 448 | 449 | # Check if we're still connected to the same AP 450 | if not self._check_bssid(): 451 | self._abort("BSSID check failed") 452 | return 453 | 454 | # Get all signal metrics from nl 455 | self.setStatus( 456 | 'Getting signal metrics (Quality, signal strength, etc.)...') 457 | data = self.collector.scanner.get_iface_data() 458 | # Merge dicts 459 | res = {**res, **data} 460 | self.survey_points[-1].set_progress(4, steps) 461 | 462 | # Scan APs in the neighborhood 463 | if self.parent.scan: 464 | self.setStatus( 465 | 'Scanning all access points within reach...') 466 | res['scan_results'] = self.collector.scan_all_access_points() 467 | self.survey_points[-1].set_progress(5, steps) 468 | 469 | # Save results and mark survey point as complete 470 | self.survey_points[-1].set_result(res) 471 | self.survey_points[-1].set_is_finished() 472 | self.setStatus( 473 | 'Saving to: %s' % self.data_filename 474 | ) 475 | self._write_json() 476 | self._ding() 477 | 478 | # any thread 479 | def _ding(self): 480 | if self.parent.ding_path is None: 481 | return 482 | subprocess.call([self.parent.ding_command, self.parent.ding_path]) 483 | 484 | # any thread 485 | def _write_json(self): 486 | # Only store finished survey points 487 | survey_points = [p.as_dict for p in self.survey_points if p.is_finished] 488 | 489 | res = json.dumps( 490 | {'img_path': self.img_path, 'survey_points': survey_points}, 491 | cls=SafeEncoder, indent=2 492 | ) 493 | with open(self.data_filename, 'w') as fh: 494 | fh.write(res) 495 | self.setStatus( 496 | 'Saved to %s; ready...' % self.data_filename 497 | ) 498 | 499 | # UI thread only 500 | def warn(self, message, caption='Warning!'): 501 | dlg = wx.MessageDialog(self.parent, message, caption, 502 | wx.OK | wx.ICON_WARNING) 503 | dlg.ShowModal() 504 | dlg.Destroy() 505 | 506 | # UI thread only 507 | def YesNo(self, question, caption='Yes or no?'): 508 | dlg = wx.MessageDialog(self.parent, question, caption, 509 | wx.YES_NO | wx.ICON_QUESTION) 510 | result = dlg.ShowModal() == wx.ID_YES 511 | dlg.Destroy() 512 | return result 513 | 514 | # Any thread 515 | def run_iperf(self, count, udp, reverse): 516 | proto = "UDP" if udp else "TCP" 517 | # iperf3 default direction is uploading to the server 518 | direction = "Download" if reverse else "Upload" 519 | self.setStatus( 520 | 'Running iperf %d/4: %s (%s) - takes %i seconds' % (count, 521 | direction, 522 | proto, 523 | self._duration) 524 | ) 525 | tmp = self.collector.run_iperf(udp, reverse) 526 | if tmp.error is None: 527 | return tmp 528 | # else this is an error 529 | if tmp.error.startswith('unable to connect to server'): 530 | self.warn( 531 | 'ERROR: Unable to connect to iperf server at {}. Aborting.'. 532 | format(self.collector._iperf_server) 533 | ) 534 | return None 535 | if self.YesNo('iperf error: %s. Retry?' % tmp.error): 536 | self.Refresh() 537 | return self.run_iperf(count, udp, reverse) 538 | # else bail out 539 | return tmp 540 | 541 | # UI thread only 542 | def on_paint(self, event=None): 543 | dc = wx.ClientDC(self) 544 | for p in self.survey_points: 545 | p.draw(dc) 546 | 547 | 548 | class MainFrame(wx.Frame): 549 | 550 | def __init__( 551 | self, img_path, server, survey_title, scan, bssid, ding, 552 | ding_command, duration, scanner, *args, **kw 553 | ): 554 | super(MainFrame, self).__init__(*args, **kw) 555 | self.img_path = img_path 556 | self.server = server 557 | self.scan = scan 558 | self.survey_title = survey_title 559 | self.bssid = None 560 | if bssid: 561 | self.bssid = bssid.lower() 562 | self.ding_path = ding 563 | self.ding_command = ding_command 564 | self.duration = duration 565 | self.CreateStatusBar() 566 | self.scanner = scanner 567 | self.pnl = FloorplanPanel(self) 568 | self.makeMenuBar() 569 | 570 | def makeMenuBar(self): 571 | fileMenu = wx.Menu() 572 | fileMenu.AppendSeparator() 573 | exitItem = fileMenu.Append(wx.ID_EXIT) 574 | menuBar = wx.MenuBar() 575 | menuBar.Append(fileMenu, "&File") 576 | self.SetMenuBar(menuBar) 577 | self.Bind(wx.EVT_MENU, self.OnExit, exitItem) 578 | 579 | def OnExit(self, event): 580 | """Close the frame, terminating the application.""" 581 | self.Close(True) 582 | 583 | 584 | def parse_args(argv): 585 | """ 586 | parse arguments/options 587 | 588 | this uses the new argparse module instead of optparse 589 | see: <https://docs.python.org/2/library/argparse.html> 590 | """ 591 | p = argparse.ArgumentParser(description='wifi survey data collection UI') 592 | p.add_argument('-v', '--verbose', dest='verbose', action='count', default=0, 593 | help='verbose output. specify twice for debug-level output.') 594 | p.add_argument('-S', '--scan', dest='scan', action='store_true', 595 | default=False, help='Scan for access points in the vicinity') 596 | p.add_argument('-s', '--server', dest='IPERF3_SERVER', action='store', type=str, 597 | default=None, help='iperf3 server IP or hostname') 598 | p.add_argument('-d', '--duration', dest='IPERF3_DURATION', action='store', 599 | type=int, default=10, 600 | help='Duration of each individual ipref3 test run') 601 | p.add_argument('-b', '--bssid', dest='BSSID', action='store', type=str, 602 | default=None, help='Restrict survey to this BSSID') 603 | p.add_argument('--ding', dest='ding', action='store', type=str, 604 | default=None, 605 | help='Path to audio file to play when measurement finishes') 606 | p.add_argument('--ding-command', dest='ding_command', action='store', 607 | type=str, default='/usr/bin/paplay', 608 | help='Path to ding command') 609 | p.add_argument('-i', '--interface', dest='INTERFACE', action='store', 610 | type=str, default=None, 611 | help='Wireless interface name') 612 | p.add_argument('-p', '--picture', dest='IMAGE', type=str, 613 | default=None, help='Path to background image') 614 | p.add_argument('-t', '--title', dest='TITLE', type=str, 615 | default=None, help='Title for survey (and data filename)' 616 | ) 617 | p.add_argument('--libnl-debug', dest='libnl_debug', action='store_true', 618 | default=False, 619 | help='enable debug-level logging for libnl') 620 | args = p.parse_args(argv) 621 | return args 622 | 623 | 624 | def set_log_info(): 625 | """set logger level to INFO""" 626 | set_log_level_format(logging.INFO, 627 | '%(asctime)s %(levelname)s:%(name)s:%(message)s') 628 | 629 | 630 | def set_log_debug(): 631 | """set logger level to DEBUG, and debug-level output format""" 632 | set_log_level_format( 633 | logging.DEBUG, 634 | "%(asctime)s [%(levelname)s %(filename)s:%(lineno)s - " 635 | "%(name)s.%(funcName)s() ] %(message)s" 636 | ) 637 | 638 | 639 | def set_log_level_format(level, format): 640 | """ 641 | Set logger level and format. 642 | 643 | :param level: logging level; see the :py:mod:`logging` constants. 644 | :type level: int 645 | :param format: logging formatter format string 646 | :type format: str 647 | """ 648 | formatter = logging.Formatter(fmt=format) 649 | logger.handlers[0].setFormatter(formatter) 650 | logger.setLevel(level) 651 | 652 | 653 | def ask_for_wifi_iface(app, scanner): 654 | frame = wx.Frame(None) 655 | title = 'Wireless interface' 656 | description = 'Please specify the wireless interface\nto be used for your survey' 657 | dlg = wx.SingleChoiceDialog(frame, description, title, scanner.iface_names) 658 | if dlg.ShowModal() == wx.ID_OK: 659 | resu = dlg.GetStringSelection() 660 | else: 661 | # User clicked [Cancel] 662 | exit() 663 | dlg.Destroy() 664 | frame.Destroy() 665 | 666 | return resu 667 | 668 | 669 | def ask_for_title(app): 670 | frame = wx.Frame(None) 671 | title = 'Title of your measurement' 672 | description = 'Please specify a title for your measurement. This title will be used to store the results and to distinguish the generated plots' 673 | default = 'Example' 674 | dlg = wx.TextEntryDialog(frame, description, title) 675 | dlg.SetValue(default) 676 | if dlg.ShowModal() == wx.ID_OK: 677 | resu = dlg.GetValue() 678 | else: 679 | # User clicked [Cancel] 680 | exit() 681 | dlg.Destroy() 682 | frame.Destroy() 683 | 684 | return resu 685 | 686 | 687 | def ask_for_floorplan(app): 688 | frame = wx.Frame(None) 689 | title = 'Select floorplan for your measurement' 690 | dlg = wx.FileDialog(frame, title, 691 | wildcard='Compatible image files (*.png, *.jpg,*.tiff, *.bmp)|*.png;*.jpg;*.tiff;*.bmp;*:PNG;*.JPG;*.TIFF;*.BMP;*.jpeg;*.JPEG', 692 | style=wx.FD_FILE_MUST_EXIST) 693 | if dlg.ShowModal() == wx.ID_OK: 694 | resu = dlg.GetPath() 695 | print(resu) 696 | else: 697 | # User clicked [Cancel] 698 | exit() 699 | dlg.Destroy() 700 | frame.Destroy() 701 | 702 | return resu 703 | 704 | 705 | def main_root(): 706 | data = json.loads(sys.stdin.readline()) 707 | if data["cmd"] != "init": 708 | sys.stderr.print("Invalid command tuple:" + json.dumps(data)) 709 | return 710 | 711 | scanner = Scanner(scan=False) 712 | scanner.set_interface(data["interface"]) 713 | 714 | sys.stdout.write(json.dumps({"status": "ok", "data":None})+"\n") 715 | sys.stdout.flush() 716 | 717 | while True: 718 | data = json.loads(sys.stdin.readline()) 719 | if data["cmd"] == "get_current_bssid": 720 | result = scanner.get_current_bssid() 721 | elif data["cmd"] == "get_iface_data": 722 | result = scanner.get_iface_data() 723 | elif data["cmd"] == "scan_all_access_points": 724 | result = scanner.scan_all_access_points() 725 | else: 726 | sys.stderr.print("Invalid action tuple:" + json.dumps(data)) 727 | return 728 | sys.stdout.write(json.dumps({"status": "ok", "data": result})+"\n") 729 | sys.stdout.flush() 730 | 731 | class RemoteScanner(object): 732 | 733 | def __init__(self, popen, scan=True, interface=None): 734 | super().__init__() 735 | logger.debug( 736 | 'Initializing RemoteScanner interface: %s', 737 | interface 738 | ) 739 | self.p = popen 740 | # initialize the subprocess 741 | self._write({"cmd": "init", "interface": interface}) 742 | self.interface_name = interface 743 | 744 | def _write(self, data): 745 | txt = json.dumps(data) 746 | self.p.stdin.write(f"{txt}\n") 747 | self.p.stdin.flush() 748 | result = self.p.stdout.readline() 749 | logger.debug(result) 750 | if result == "" or result == "\n": 751 | raise "Subprocess exited" 752 | obj = json.loads(result) 753 | if obj["status"] != "ok": 754 | logger.warn(result) 755 | raise obj 756 | else: 757 | return obj["data"] 758 | 759 | 760 | def get_current_bssid(self): 761 | return self._write({"cmd": "get_current_bssid"}) 762 | 763 | def get_iface_data(self): 764 | return self._write({"cmd": "get_iface_data"}) 765 | 766 | def scan_all_access_points(self): 767 | return self._write({"cmd": "scan_all_access_points"}) 768 | 769 | SECRET_ELEVATED_CHILD = "--internal-elevated-scannner" 770 | 771 | 772 | def main(): 773 | if sys.argv[1:] == [SECRET_ELEVATED_CHILD]: 774 | if os.geteuid() != 0: 775 | raise RuntimeError('ERROR: This script must be run as root/sudo.') 776 | main_root() 777 | return 778 | 779 | p = None 780 | if os.getuid() != 0: 781 | pass # we can parse the args first 782 | else: 783 | if os.getenv("SUDO_UID") is not None: 784 | # Drop to the sudo UID after we span the child 785 | p = Popen([sys.executable, sys.argv[0], SECRET_ELEVATED_CHILD], stdin=PIPE, stdout=PIPE,text=True) 786 | uid = int(os.getenv("SUDO_UID")) 787 | gid = int(os.getenv("SUDO_GID")) 788 | logger.warning("Launched process via SUDO UID, spawned privledged child and dropping permissiong to uid=" + str(uid)) 789 | os.setgid(gid) 790 | os.setuid(uid) 791 | else: 792 | logger.warning("You should not run this script as root.") 793 | 794 | # Parse input arguments 795 | args = parse_args(sys.argv[1:]) 796 | 797 | # set logging level 798 | if args.verbose > 1: 799 | set_log_debug() 800 | elif args.verbose == 1: 801 | set_log_info() 802 | 803 | if not args.libnl_debug: 804 | for lname in ['libnl']: 805 | log = logging.getLogger(lname) 806 | log.setLevel(logging.WARNING) 807 | log.propagate = True 808 | 809 | app = wx.App() 810 | 811 | if args.scan and p is None: 812 | p = Popen(['pkexec', 'env', 'HOME='+os.getenv("HOME"), sys.executable, sys.argv[0], SECRET_ELEVATED_CHILD], stdout=PIPE, stdin=PIPE, text=True) 813 | 814 | scanner = Scanner(scan=args.scan) 815 | 816 | # Ask for possibly missing fields 817 | # Wireless interface 818 | if args.INTERFACE is None: 819 | INTERFACE = ask_for_wifi_iface(app, scanner) 820 | else: 821 | INTERFACE = args.INTERFACE 822 | 823 | # Definitely set interface at this point 824 | scanner.set_interface(INTERFACE) 825 | 826 | # Floorplan image 827 | if args.IMAGE is None: 828 | IMAGE = ask_for_floorplan(app) 829 | else: 830 | IMAGE = args.IMAGE 831 | 832 | # Title 833 | if args.TITLE is None: 834 | TITLE = ask_for_title(app) 835 | else: 836 | TITLE = args.TITLE 837 | 838 | if p is not None: 839 | scanner = RemoteScanner(p, interface=INTERFACE) 840 | 841 | frm = MainFrame( 842 | IMAGE, args.IPERF3_SERVER, TITLE, args.scan, 843 | args.BSSID, args.ding, args.ding_command, args.IPERF3_DURATION, 844 | scanner, None, title='wifi-survey: %s' % args.TITLE, 845 | ) 846 | frm.Show() 847 | frm.SetStatusText('%s' % frm.pnl.GetSize()) 848 | app.MainLoop() 849 | 850 | 851 | if __name__ == '__main__': 852 | main() 853 | -------------------------------------------------------------------------------- /wifi_survey_heatmap/version.py: -------------------------------------------------------------------------------- 1 | """ 2 | The latest version of this package is available at: 3 | <http://github.com/jantman/wifi-survey-heatmap> 4 | 5 | ################################################################################## 6 | Copyright 2018 Jason Antman <jason@jasonantman.com> <http://www.jasonantman.com> 7 | 8 | This file is part of wifi-survey-heatmap, also known as wifi-survey-heatmap. 9 | 10 | wifi-survey-heatmap is free software: you can redistribute it and/or modify 11 | it under the terms of the GNU Affero General Public License as published by 12 | the Free Software Foundation, either version 3 of the License, or 13 | (at your option) any later version. 14 | 15 | wifi-survey-heatmap is distributed in the hope that it will be useful, 16 | but WITHOUT ANY WARRANTY; without even the implied warranty of 17 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 | GNU Affero General Public License for more details. 19 | 20 | You should have received a copy of the GNU Affero General Public License 21 | along with wifi-survey-heatmap. If not, see <http://www.gnu.org/licenses/>. 22 | 23 | The Copyright and Authors attributions contained herein may not be removed or 24 | otherwise altered, except to add the Author attribution of a contributor to 25 | this work. (Additional Terms pursuant to Section 7b of the AGPL v3) 26 | ################################################################################## 27 | While not legally required, I sincerely request that anyone who finds 28 | bugs please submit them at <https://github.com/jantman/wifi-survey-heatmap> or 29 | to me via email, and that you send any contributions or improvements 30 | either as a pull request on GitHub, or to me via email. 31 | ################################################################################## 32 | 33 | AUTHORS: 34 | Jason Antman <jason@jasonantman.com> <http://www.jasonantman.com> 35 | ################################################################################## 36 | """ 37 | 38 | VERSION = '1.2.0' 39 | PROJECT_URL = 'https://github.com/jantman/wifi-survey-heatmap' 40 | --------------------------------------------------------------------------------