diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bfe1a84 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,70 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +bin/ +include/ +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt +pip-selfcheck.json + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/build/ + +# PyBuilder +target/ + +# virtualenv +bin/ +include/ + +.idea/ +result.json +*.json +/*.png diff --git a/.gitignore b/.gitignore index e4eda0d..7f54e70 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,5 @@ result.json /tcp_*.png /udp_*.png /channels*.png + +pyvenv.cfg diff --git a/CHANGES.rst b/CHANGES.rst index 21dd0e2..44cc2c1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,7 +1,16 @@ Changelog ========= -0.1.0 (YYYY-MM-DD) +0.2.0 (2020-08-09) +------------------ + +* Package via Docker, for easier usage without worrying about dependencies. +* Optional AP name/band annotations on heatmap. +* Add CLI option to disable iwlist scans. +* Add ability to remove survey points. +* Add ability to drag (move) survey points. + +0.1.0 (2018-10-30) ------------------ * Initial release diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d96eb86 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,47 @@ +FROM debian:buster-20200720 + +ARG build_date +ARG repo_url +ARG repo_ref +USER root + +RUN apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + iperf3 \ + gcc \ + git \ + libiw-dev \ + python3 \ + python3-cffi \ + python3-dev \ + python3-matplotlib \ + python3-pip \ + python3-scipy \ + python3-setuptools \ + python3-wheel \ + python3-wxgtk4.0 \ + wireless-tools && \ + pip3 install \ + iperf3 \ + iwlib + +COPY . /app + +RUN cd /app && \ + python3 setup.py develop && \ + pip3 freeze > /app/requirements.installed + +LABEL maintainer="jason@jasonantman.com" \ + org.label-schema.build-date="$build_date" \ + org.label-schema.name="jantman/python-wifi-survey-heatmap" \ + org.label-schema.url="https://github.com/jantman/python-wifi-survey-heatmap" \ + org.label-schema.vcs-url="$repo_url" \ + org.label-schema.vcs-ref="$repo_ref" \ + org.label-schema.version="$repo_ref" \ + org.label-schema.schema-version="1.0" + +# For the iperf server, if using for the server side +EXPOSE 5201/tcp +EXPOSE 5201/udp + +CMD /bin/bash diff --git a/README.rst b/README.rst index 8577d81..30f9c7d 100644 --- a/README.rst +++ b/README.rst @@ -5,6 +5,10 @@ python-wifi-survey-heatmap :alt: Project Status: WIP – Initial development is in progress, but there has not yet been a stable, usable release suitable for the public. :target: https://www.repostatus.org/#wip +.. image:: https://img.shields.io/docker/cloud/build/jantman/python-wifi-survey-heatmap.svg + :alt: Docker Hub Build Status + :target: https://hub.docker.com/r/jantman/python-wifi-survey-heatmap + A Python application for Linux machines to perform WiFi site surveys and present the results as a heatmap overlayed on a floorplan. @@ -14,6 +18,8 @@ This is very rough, very alpha code. The heatmap generation code is roughly base Installation and Dependencies ----------------------------- +**NOTE: These can all be ignored when using Docker. See below.** + * The Python `iwlib `_ package, which needs cffi and the Linux ``wireless_tools`` package. * The Python `iperf3 `_ package, which needs `iperf3 `_ installed on your system. * `wxPython Phoenix `_, which unfortunately must be installed using OS packages or built from source. @@ -58,16 +64,25 @@ First connect to the network that you want to survey. Then, run ``sudo wifi-surv If ``Title.json`` already exists, the data from it will be pre-loaded into the application; this can be used to resume a survey. -When the UI loads, you should see your PNG file displayed. If you click on a point on the PNG, 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. The output file is (re-)written after each measurement completes, so just exit the app when you're finished (or want to resume later). 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. +When the UI loads, you should see your PNG file displayed. The UI is really simple: + +* 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. +* 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). +* Right (secondary) clicking a point will allow you to delete it. You'll be prompted to confirm. +* 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. 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. +**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. + Heatmap Generation ++++++++++++++++++ Once you've performed a survey with a given title and the results are saved in ``Title.json``, run ``wifi-heatmap PNG 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. -The end result of this process for a given survey (Title) should be XX ``.png`` images in your current directory: +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. + +The end result of this process for a given survey (Title) should be 8 ``.png`` images in your current directory: * **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) * **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) @@ -78,6 +93,39 @@ The end result of this process for a given survey (Title) should be XX ``.png`` * **tcp_upload_Mbps_TITLE.png** - Heatmap of iperf3 transfer rate, TCP, uploading from client to server. * **udp_Mbps_TITLE.png** - Heatmap of iperf3 transfer rate, UDP, uploading from client to server. +Running In Docker +----------------- + +Survey +++++++ + +.. code-block:: bash + + docker run \ + --net="host" \ + --privileged \ + --name survey \ + -it \ + --rm \ + -v $(pwd):/pwd \ + -w /pwd \ + -e DISPLAY=$DISPLAY \ + -v "$HOME/.Xauthority:/root/.Xauthority:ro" \ + jantman/python-wifi-survey-heatmap \ + wifi-survey INTERFACE SERVER FLOORPLAN.png TITLE + +Note that running with ``--net="host"`` and ``--privileged`` is required in order to manipulate the host's wireless interface. + +Heatmap ++++++++ + +``docker run -it --rm -v $(pwd):/pwd -w /pwd jantman/python-wifi-survey-heatmap:23429a4 wifi-heatmap floorplan.png DeckTest`` + +iperf3 server ++++++++++++++ + +Server: ``docker run -it --rm -p 5201:5201/tcp -p 5201:5201/udp jantman/python-wifi-survey-heatmap iperf3 -s`` + Examples -------- diff --git a/build_docker.sh b/build_docker.sh new file mode 100755 index 0000000..a22657b --- /dev/null +++ b/build_docker.sh @@ -0,0 +1,15 @@ +#!/bin/bash -ex + +cd "$( dirname "${BASH_SOURCE[0]}" )" +BDATE=$(date +%Y-%m-%dT%H:%M:%S.00%Z) +REF=$(git rev-parse --short HEAD) +if ! git diff --no-ext-diff --quiet --exit-code || ! git diff-index --cached --quiet HEAD --; then + REF="${REF}-dirty" +fi + +docker build \ + --build-arg "build_date=${BDATE}" \ + --build-arg "repo_url=$(git config remote.origin.url)" \ + --build-arg "repo_ref=${REF}" \ + -t jantman/python-wifi-survey-heatmap:${REF} \ + . diff --git a/setup.py b/setup.py index 111e00d..b1e6b68 100644 --- a/setup.py +++ b/setup.py @@ -44,8 +44,8 @@ requires = [ 'cffi>=1.0.0', 'iperf3==0.1.10', - 'matplotlib==3.0.1', - 'scipy==1.1.0' + 'matplotlib==3.3.0', + 'scipy==1.5.2' ] classifiers = [ diff --git a/tox.ini b/tox.ini index 1a1408c..63efd1d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27,py34,py35,py36,py37,docs +envlist = py27,py34,py35,py36,py37,docs,docker [testenv] deps = @@ -58,3 +58,12 @@ commands = sphinx-build -a -n -W -b linkcheck {toxinidir}/docs/source {toxinidir}/docs/build/html # build sphinx-build -a -n -W -b html {toxinidir}/docs/source {toxinidir}/docs/build/html + +[testenv:docker] +passenv = TRAVIS* CONTINUOUS_INTEGRATION AWS* READTHEDOCS* +setenv = + TOXINIDIR={toxinidir} + TOXDISTDIR={distdir} + CI=true +commands = + bash build_docker.sh diff --git a/wifi_survey_heatmap/collector.py b/wifi_survey_heatmap/collector.py index 5dc54e9..2766cef 100644 --- a/wifi_survey_heatmap/collector.py +++ b/wifi_survey_heatmap/collector.py @@ -48,7 +48,7 @@ class Collector(object): - def __init__(self, interface_name, server_addr): + def __init__(self, interface_name, server_addr, scan=True): super().__init__() logger.debug( 'Initializing Collector for interface: %s; iperf server: %s', @@ -56,6 +56,7 @@ def __init__(self, interface_name, server_addr): ) self._interface_name = interface_name self._iperf_server = server_addr + self._scan = scan def run_iperf(self, udp=False, reverse=False): client = iperf3.Client() @@ -109,7 +110,8 @@ def run(self): logger.debug('Getting iwconfig...') res['config'] = get_iwconfig(self._interface_name) logger.debug('iwconfig result: %s', res['config']) - logger.debug('Scanning...') - res['scan'] = scan(self._interface_name) - logger.debug('scan result: %s', res['scan']) + if self._scan: + logger.debug('Scanning...') + res['scan'] = scan(self._interface_name) + logger.debug('scan result: %s', res['scan']) return res diff --git a/wifi_survey_heatmap/heatmap.py b/wifi_survey_heatmap/heatmap.py index e3ec4ad..d12e6ed 100644 --- a/wifi_survey_heatmap/heatmap.py +++ b/wifi_survey_heatmap/heatmap.py @@ -48,6 +48,7 @@ from pylab import imread, imshow from matplotlib.offsetbox import AnchoredText from matplotlib.patheffects import withStroke +from matplotlib.font_manager import FontManager import matplotlib @@ -126,7 +127,13 @@ class HeatMapGenerator(object): - def __init__(self, image_path, title, ignore_ssids=[]): + def __init__(self, image_path, title, ignore_ssids=[], aps=None): + self._ap_names = {} + if aps is not None: + with open(aps, 'r') as fh: + self._ap_names = { + x.upper(): y for x, y in json.loads(fh.read()).items() + } self._image_path = image_path self._title = title self._ignore_ssids = ignore_ssids @@ -137,6 +144,10 @@ def __init__(self, image_path, title, ignore_ssids=[]): self._layout = imread(self._image_path) self._image_width = len(self._layout[0]) self._image_height = len(self._layout) - 1 + self._corners = [ + (0, 0), (0, self._image_height), + (self._image_width, 0), (self._image_width, self._image_height) + ] logger.debug( 'Loaded image with width=%d height=%d', self._image_width, self._image_height @@ -158,15 +169,21 @@ def generate(self): ) a['udp_Mbps'].append(row['result']['udp']['Mbps']) a['jitter'].append(row['result']['udp']['jitter_ms']) - for x, y in [ - (0, 0), (0, self._image_height), - (self._image_width, 0), (self._image_width, self._image_height) - ]: + ap = self._ap_names.get( + row['result']['iwconfig']['Access Point'].upper(), + row['result']['iwconfig']['Access Point'] + ) + if row['result']['iwconfig']['Frequency'].startswith('2.4'): + a['ap'].append(ap + '_2.4') + else: + a['ap'].append(ap + '_5G') + for x, y in self._corners: a['x'].append(x) a['y'].append(y) for k in a.keys(): - if k in ['x', 'y']: + if k in ['x', 'y', 'ap']: continue + a['ap'].append(None) a[k] = [0 if x is None else x for x in a[k]] a[k].append(min(a[k])) self._channel_graphs() @@ -303,13 +320,21 @@ def _plot(self, a, key, title, gx, gy, num_x, num_y): ) pp.colorbar(image) pp.imshow(self._layout, interpolation='bicubic', zorder=1, alpha=1) + labelsize = FontManager.get_default_size() * 0.4 # begin plotting points for idx in range(0, len(a['x'])): + if (a['x'][idx], a['y'][idx]) in self._corners: + continue pp.plot( a['x'][idx], a['y'][idx], marker='o', markeredgecolor='black', markeredgewidth=1, markerfacecolor=mapper.to_rgba(a[key][idx]), markersize=6 ) + pp.text( + a['x'][idx], a['y'][idx] - 30, + a['ap'][idx], fontsize=labelsize, + horizontalalignment='center' + ) # end plotting points fname = '%s_%s.png' % (key, self._title) logger.info('Writing plot to: %s', fname) @@ -329,6 +354,12 @@ def parse_args(argv): help='verbose output. specify twice for debug-level output.') p.add_argument('-i', '--ignore', dest='ignore', action='append', default=[], help='SSIDs to ignore from channel graph') + p.add_argument('-a', '--ap-names', type=str, dest='aps', action='store', + default=None, + help='If specified, a JSON file mapping AP MAC/BSSID to ' + 'a string to label each measurement with, showing ' + 'which AP it was connected to. Useful when doing ' + 'multi-AP surveys.') p.add_argument('IMAGE', type=str, help='Path to background image') p.add_argument( 'TITLE', type=str, help='Title for survey (and data filename)' @@ -376,7 +407,7 @@ def main(): set_log_info() HeatMapGenerator( - args.IMAGE, args.TITLE, ignore_ssids=args.ignore + args.IMAGE, args.TITLE, ignore_ssids=args.ignore, aps=args.aps ).generate() diff --git a/wifi_survey_heatmap/ui.py b/wifi_survey_heatmap/ui.py index 7895632..46c33b2 100644 --- a/wifi_survey_heatmap/ui.py +++ b/wifi_survey_heatmap/ui.py @@ -113,15 +113,31 @@ def set_is_failed(self): def set_is_finished(self): self.is_finished = True - def draw(self, dc): - color = 'green' - if not self.is_finished: - color = 'yellow' - if self.is_failed: - color = 'red' + def draw(self, dc, color=None): + if color is None: + color = 'green' + if not self.is_finished: + color = 'yellow' + if self.is_failed: + color = 'red' + dc.SetPen(wx.Pen(color, style=wx.TRANSPARENT)) dc.SetBrush(wx.Brush(color, wx.SOLID)) dc.DrawCircle(self.x, self.y, 20) + def erase(self, dc): + """quicker than redrawing, since DC doesn't have persistence""" + dc.SetPen(wx.Pen('white', style=wx.TRANSPARENT)) + dc.SetBrush(wx.Brush('white', wx.SOLID)) + dc.DrawCircle(self.x, self.y, 22) + + def includes_point(self, x, y): + if ( + self.x - 20 <= x <= self.x + 20 and + self.y - 20 <= y <= self.y + 20 + ): + return True + return False + class SafeEncoder(json.JSONEncoder): @@ -138,9 +154,16 @@ def __init__(self, parent): self.parent = parent self.img_path = parent.img_path self.Bind(wx.EVT_ERASE_BACKGROUND, self.OnEraseBackground) - self.Bind(wx.EVT_LEFT_UP, self.onClick) + + self.Bind(wx.EVT_LEFT_UP, self.onLeftUp) + self.Bind(wx.EVT_LEFT_DOWN, self.onLeftDown) + self.Bind(wx.EVT_MOTION, self.onMotion) + self.Bind(wx.EVT_RIGHT_UP, self.onRightClick) self.Bind(wx.EVT_PAINT, self.on_paint) self.survey_points = [] + self._moving_point = None + self._moving_x = None + self._moving_y = None self.data_filename = '%s.json' % self.parent.survey_title if os.path.exists(self.data_filename): self._load_file(self.data_filename) @@ -168,8 +191,82 @@ def OnEraseBackground(self, evt): bmp = wx.Bitmap(self.img_path) dc.DrawBitmap(bmp, 0, 0) - def onClick(self, event): - pos = event.GetPosition() + def onRightClick(self, event): + x, y = event.GetPosition() + point = None + for p in self.survey_points: + # important to iterate the whole list, so we find the most recent + if p.includes_point(x, y): + point = p + if point is None: + self.parent.SetStatusText( + f"No survey point found at ({x}, {y})" + ) + self.Refresh() + return + # ok, we have a point to remove + point.draw(wx.ClientDC(self), color='blue') + res = self.YesNo(f'Remove point at ({x}, {y}) shown in blue?') + if not res: + self.parent.SetStatusText('Not removing point.') + self.Refresh() + return + self.survey_points.remove(point) + self.parent.SetStatusText(f'Removed point at ({x}, {y})') + self.Refresh() + self._write_json() + + def onLeftDown(self, event): + x, y = event.GetPosition() + point = None + for p in self.survey_points: + # important to iterate the whole list, so we find the most recent + if p.includes_point(x, y): + point = p + if point is None: + self.parent.SetStatusText( + f"No survey point found at ({x}, {y})" + ) + self.Refresh() + return + self._moving_point = point + self._moving_x = point.x + self._moving_y = point.y + point.draw(wx.ClientDC(self), color='blue') + + def onLeftUp(self, event): + if self._moving_point is None: + self._do_measurement(event.GetPosition()) + return + x, y = event.GetPosition() + oldx = self._moving_point.x + oldy = self._moving_point.y + self._moving_point.x = x + self._moving_point.y = y + self._moving_point.draw(wx.ClientDC(self), color='red') + res = self.YesNo( + f'Move point from blue ({oldx}, {oldy}) to red ({x}, {y})?' + ) + if not res: + self._moving_point.x = self._moving_x + self._moving_point.y = self._moving_y + self._moving_point = None + self._moving_x = None + self._moving_y = None + self.Refresh() + self._write_json() + + def onMotion(self, event): + if self._moving_point is None: + return + x, y = event.GetPosition() + dc = wx.ClientDC(self) + self._moving_point.erase(dc) + self._moving_point.x = x + self._moving_point.y = y + self._moving_point.draw(dc, color='red') + + def _do_measurement(self, pos): self.parent.SetStatusText('Got click at: %s' % pos) self.survey_points.append(SurveyPoint(self, pos[0], pos[1])) self.Refresh() @@ -195,15 +292,20 @@ def onClick(self, event): self.parent.SetStatusText('Running iwconfig...') self.Refresh() res['iwconfig'] = self.collector.run_iwconfig() - self.parent.SetStatusText('Running iwscan...') self.Refresh() - res['iwscan'] = self.collector.run_iwscan() + if self.parent.scan: + self.parent.SetStatusText('Running iwscan...') + self.Refresh() + res['iwscan'] = self.collector.run_iwscan() self.survey_points[-1].set_result(res) self.survey_points[-1].set_is_finished() self.parent.SetStatusText( 'Saving to: %s' % self.data_filename ) self.Refresh() + self._write_json() + + def _write_json(self): res = json.dumps( [x.as_dict for x in self.survey_points], cls=SafeEncoder @@ -257,13 +359,14 @@ def on_paint(self, event=None): class MainFrame(wx.Frame): def __init__( - self, img_path, interface, server, survey_title, + self, img_path, interface, server, survey_title, scan, *args, **kw ): super(MainFrame, self).__init__(*args, **kw) self.img_path = img_path self.interface = interface self.server = server + self.scan = scan self.survey_title = survey_title self.CreateStatusBar() self.pnl = FloorplanPanel(self) @@ -293,6 +396,8 @@ def parse_args(argv): p = argparse.ArgumentParser(description='wifi survey data collection UI') p.add_argument('-v', '--verbose', dest='verbose', action='count', default=0, help='verbose output. specify twice for debug-level output.') + p.add_argument('-S', '--no-scan', dest='scan', action='store_false', + default=True, help='skip iwlist scan') p.add_argument('INTERFACE', type=str, help='Wireless interface name') p.add_argument('SERVER', type=str, help='iperf3 server IP or hostname') p.add_argument('IMAGE', type=str, help='Path to background image') @@ -343,7 +448,7 @@ def main(): app = wx.App() frm = MainFrame( - args.IMAGE, args.INTERFACE, args.SERVER, args.TITLE, + args.IMAGE, args.INTERFACE, args.SERVER, args.TITLE, args.scan, None, title='wifi-survey: %s' % args.TITLE ) frm.Show() diff --git a/wifi_survey_heatmap/version.py b/wifi_survey_heatmap/version.py index 9031044..e7caf1e 100644 --- a/wifi_survey_heatmap/version.py +++ b/wifi_survey_heatmap/version.py @@ -35,5 +35,5 @@ ################################################################################## """ -VERSION = '0.1.0' +VERSION = '0.2.0' PROJECT_URL = 'https://github.com/jantman/wifi-survey-heatmap'