From 6354eb29ed2ed5321509fefcfdbc793bb3aaf16f Mon Sep 17 00:00:00 2001 From: DL6ER Date: Thu, 10 Dec 2020 23:02:14 +0100 Subject: [PATCH 01/12] Draw a red point if a survey failed. Remove failed points when starting a new survey. Signed-off-by: DL6ER --- wifi_survey_heatmap/ui.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/wifi_survey_heatmap/ui.py b/wifi_survey_heatmap/ui.py index dc6d69a..80e8394 100644 --- a/wifi_survey_heatmap/ui.py +++ b/wifi_survey_heatmap/ui.py @@ -119,6 +119,7 @@ def set_progress(self, value, total): def set_is_finished(self): self.is_finished = True + self.is_failed = False self.progress = 100 def draw(self, dc, color=None): @@ -334,9 +335,17 @@ def _check_bssid(self): self.warn(msg) return False + def _abort(self, reason): + self.survey_points[-1].set_is_failed() + self.parent.SetStatusText('Aborted: {}'.format(reason)) + self.Refresh() + def _do_measurement(self, pos): - self.parent.SetStatusText('Got click at: %s' % pos) + self.parent.SetStatusText('Starting survey...') + # Add new survey point self.survey_points.append(SurveyPoint(self, pos[0], pos[1])) + # Delete failed survey points + self.survey_points = [p for p in self.survey_points if not p.is_failed] self.Refresh() res = {} count = 0 @@ -345,9 +354,11 @@ def _do_measurement(self, pos): # Check if we are connected to an AP, all the # rest doesn't any sense otherwise if not self.collector.check_associated(): + self._abort("Not connected to an access point") return # Check BSSID if not self._check_bssid(): + self._abort("BSSID check failed") return # Skip iperf test if empty server string was given @@ -360,15 +371,14 @@ def _do_measurement(self, pos): # Check if we're still connected to the same AP if not self._check_bssid(): + self._abort("BSSID check failed") return # Start iperf test tmp = self.run_iperf(count, udp, reverse) if tmp is None: # bail out; abort this survey point - del self.survey_points[-1] - self.parent.SetStatusText('Aborted; ready to retry...') - self.Refresh() + self._abort("iperf test failed") return # else success res['%s%s' % (protoname, suffix)] = { @@ -377,7 +387,7 @@ def _do_measurement(self, pos): # Check if we're still connected to the same AP if not self._check_bssid(): - del self.survey_points[-1] + self._abort("BSSID check failed") return # Get all signal metrics from nl From 09af720312b03ec3ce1574b21a034e1d2e8a1d0d Mon Sep 17 00:00:00 2001 From: DL6ER Date: Thu, 10 Dec 2020 23:03:04 +0100 Subject: [PATCH 02/12] Do not start maximized. This makes sense now that we support image scaling. Signed-off-by: DL6ER --- wifi_survey_heatmap/ui.py | 1 - 1 file changed, 1 deletion(-) diff --git a/wifi_survey_heatmap/ui.py b/wifi_survey_heatmap/ui.py index 80e8394..04107a6 100644 --- a/wifi_survey_heatmap/ui.py +++ b/wifi_survey_heatmap/ui.py @@ -598,7 +598,6 @@ def main(): title='wifi-survey: %s' % args.TITLE, ) frm.Show() - frm.Maximize(True) frm.SetStatusText('%s' % frm.pnl.GetSize()) app.MainLoop() From e9dcde570a8026e435e743dc0566df9b5d20a925 Mon Sep 17 00:00:00 2001 From: DL6ER Date: Thu, 10 Dec 2020 23:06:44 +0100 Subject: [PATCH 03/12] Change color of moving point from red to lightblue as we're now using red to indicate failure. Signed-off-by: DL6ER --- wifi_survey_heatmap/ui.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/wifi_survey_heatmap/ui.py b/wifi_survey_heatmap/ui.py index 04107a6..cd95229 100644 --- a/wifi_survey_heatmap/ui.py +++ b/wifi_survey_heatmap/ui.py @@ -283,7 +283,7 @@ def onLeftDown(self, event): self._moving_point = point self._moving_x = point.x self._moving_y = point.y - point.draw(wx.ClientDC(self), color='blue') + point.draw(wx.ClientDC(self), color='lightblue') def onLeftUp(self, event): x, y = pos = self.get_xy(event) @@ -294,9 +294,9 @@ def onLeftUp(self, event): oldy = self._moving_point.y self._moving_point.x = x self._moving_point.y = y - self._moving_point.draw(wx.ClientDC(self), color='red') + self._moving_point.draw(wx.ClientDC(self), color='lightblue') res = self.YesNo( - f'Move point from blue ({oldx}, {oldy}) to red ({x}, {y})?' + f'Move point from ({oldx}, {oldy}) to ({x}, {y})?' ) if not res: self._moving_point.x = self._moving_x @@ -315,7 +315,7 @@ def onMotion(self, event): self._moving_point.erase(dc) self._moving_point.x = x self._moving_point.y = y - self._moving_point.draw(dc, color='red') + self._moving_point.draw(dc, color='lightblue') def _check_bssid(self): # Return early if BSSID is not to be verified From ab44db2ad042eb32feb9d33632f9d3293a3467e8 Mon Sep 17 00:00:00 2001 From: DL6ER Date: Thu, 10 Dec 2020 23:10:11 +0100 Subject: [PATCH 04/12] 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 Signed-off-by: DL6ER --- README.rst | 24 ++++++++++++------------ wifi_survey_heatmap/ui.py | 21 +++++++++++---------- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/README.rst b/README.rst index f6944cf..c8cd3af 100644 --- a/README.rst +++ b/README.rst @@ -20,7 +20,7 @@ Quick start 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`). Creating a heatmap using the software consists of the following three essential steps: -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. +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. 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. 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. @@ -32,7 +32,7 @@ Installation and Dependencies * The Python `iperf3 `_ package, which needs `iperf3 `_ installed on your system. * The Python `libiw `_ package. * `wxPython Phoenix `_, which unfortunately must be installed using OS packages or built from source. -* An iperf3 server running on another system on the LAN, as described below. +* An iperf3 server running on another system on the LAN, as described below is recommended but optional. Recommended installation is via ``python setup.py develop`` in a virtualenv setup with ``--system-site-packages`` (for the above dependencies). @@ -43,15 +43,15 @@ Data Collection At each survey location, data collection should take 45-60 seconds. The data collected is currently: -* 10-second iperf3 measurement, TCP, client (this app) sending to server, default iperf3 options -* 10-second iperf3 measurement, TCP, server sending to client, default iperf3 options -* 10-second iperf3 measurement, UDP, client (this app) sending to server, default iperf3 options +* 10-second iperf3 measurement, TCP, client (this app) sending to server, default iperf3 options [optional, enable with `--server`] +* 10-second iperf3 measurement, TCP, server sending to client, default iperf3 options [optional, enable with `--server`] +* 10-second iperf3 measurement, UDP, client (this app) sending to server, default iperf3 options [optional, enable with `--server`] * Recording of various WiFi details such as advertised channel bandwidth, bitrate, or signal strength -* Scan of all visible access points in the vicinity +* Scan of all visible access points in the vicinity [optional, enable with `--scan`] Hints: - 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. -- Scanning for other network takes rather long. As this isn't required in most cases, you can skip this using `wifi-survey --no-scan` +- Scanning for other network takes rather long. As this isn't required in most cases, it is not enabled by default Usage ----- @@ -68,10 +68,9 @@ Performing a Survey The survey tool (``wifi-survey``) must be run as root or via ``sudo`` in order to use iwconfig/iwlist. -First connect to the network that you want to survey. Then, run ``sudo wifi-survey INTERFACE SERVER PNG Title`` where: +First connect to the network that you want to survey. Then, run ``sudo wifi-survey INTERFACE PNG Title`` where: * ``INTERFACE`` is the name of your Wireless interface (e.g. ``wlp3s0``) -* ``SERVER`` is the IP address or hostname of the iperf3 server * ``PNG`` 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 0: + if self.collector._iperf_server is not None: for protoname, udp in {'tcp': False, 'udp': True}.items(): for suffix, reverse in {'': False, '-reverse': True}.items(): # Update progress mark @@ -525,9 +525,14 @@ 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 access point scan') - p.add_argument('-b', '--bssid', dest='bssid', action='store', type=str, + p.add_argument('-S', '--scan', dest='scan', action='store_true', + default=False, help='Scan for access points in the vicinity') + p.add_argument('-s', '--server', dest='IPERF3_SERVER', action='store', type=str, + default=None, help='iperf3 server IP or hostname') + p.add_argument('-d', '--duration', dest='IPERF3_DURATION', action='store', + type=int, default=10, + help='Duration of each individual ipref3 test run') + p.add_argument('-b', '--bssid', dest='BSSID', action='store', type=str, default=None, help='Restrict survey to this BSSID') p.add_argument('--ding', dest='ding', action='store', type=str, default=None, @@ -535,11 +540,7 @@ def parse_args(argv): p.add_argument('--ding-command', dest='ding_command', action='store', type=str, default='/usr/bin/paplay', help='Path to ding command') - p.add_argument('-d', '--duration', dest='duration', action='store', - type=int, default=10, - help='Duration of each individual ipref test run') 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') p.add_argument( 'TITLE', type=str, help='Title for survey (and data filename)' @@ -593,8 +594,8 @@ def main(): app = wx.App() frm = MainFrame( - args.IMAGE, args.INTERFACE, args.SERVER, args.TITLE, args.scan, - args.bssid, args.ding, args.ding_command, args.duration, None, + args.IMAGE, args.INTERFACE, args.IPERF3_SERVER, args.TITLE, args.scan, + args.BSSID, args.ding, args.ding_command, args.IPERF3_DURATION, None, title='wifi-survey: %s' % args.TITLE, ) frm.Show() From aa49de1c28829a0ead6cc80fbb7bcca18ef91941 Mon Sep 17 00:00:00 2001 From: DL6ER Date: Thu, 10 Dec 2020 23:16:51 +0100 Subject: [PATCH 05/12] Add frequency graph and differentiate between upload and download UDP jitter. Signed-off-by: DL6ER --- README.rst | 10 ++++++---- wifi_survey_heatmap/heatmap.py | 36 ++++++++++++++++++++++------------ 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/README.rst b/README.rst index c8cd3af..a48981c 100644 --- a/README.rst +++ b/README.rst @@ -121,14 +121,16 @@ The end result of this process for a given survey (Title) should be some ``.png` * `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) -* `jitter_TITLE.png` - Heatmap based on UDP jitter measurement in milliseconds. -* `rss_TITLE.png` - Heatmap based on the received signal strength. +* `signal_quality_TITLE.png` - Heatmap based on the received signal strength. +* `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. * `tcp_download_Mbps_TITLE.png` - Heatmap of `iperf3` transfer rate, TCP, downloading from server to client. * `tcp_upload_Mbps_TITLE.png` - Heatmap of `iperf3` transfer rate, TCP, uploading from client to server. +* `udp_download_Mbps_TITLE.png` - Heatmap of `iperf3` transfer rate, UDP, downloading from server to client. * `udp_upload_Mbps_TITLE.png` - Heatmap of `iperf3` transfer rate, UDP, uploading from client to server. +* `jitter_download_TITLE.png` - Heatmap based on UDP jitter measurement in milliseconds. +* `jitter_upload_TITLE.png` - Heatmap based on UDP jitter measurement in milliseconds. * `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). -* `channel_rx_bitrate_TITLE.png` - Heatmap of advertised channel bandwidth in RX direction (AP to client) -* `channel_tx_bitrate_TITLE.png` - Heatmap of advertised channel bandwidth in TX direction (client to AP) +* `channel_bitrate_TITLE.png` - Heatmap of negotiated channel bandwidth 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. diff --git a/wifi_survey_heatmap/heatmap.py b/wifi_survey_heatmap/heatmap.py index 116c37c..78cee04 100644 --- a/wifi_survey_heatmap/heatmap.py +++ b/wifi_survey_heatmap/heatmap.py @@ -136,10 +136,11 @@ class HeatMapGenerator(object): 'udp_download_Mbps': 'Download (UDP) [MBit/s]', 'tcp_upload_Mbps': 'Upload (TCP) [MBit/s]', 'udp_upload_Mbps': 'Upload (UDP) [MBit/s]', - 'jitter': 'UDP Jitter [ms]', + 'jitter_download': 'UDP Download Jitter [ms]', + 'jitter_upload': 'UDP Upload Jitter [ms]', + 'frequency': 'Wi-Fi frequency [GHz]', 'channel': 'Wi-Fi channel', 'channel_bitrate': 'Maximum channel bandwidth [MBit/s]', - } def __init__( @@ -183,18 +184,24 @@ def load_data(self): a['x'].append(row['x']) a['y'].append(row['y']) a['channel'].append(row['result']['channel']) - a['tcp_upload_Mbps'].append( - row['result']['tcp']['received_Mbps'] - ) - a['tcp_download_Mbps'].append( - row['result']['tcp-reverse']['received_Mbps'] - ) - a['udp_download_Mbps'].append(row['result']['udp']['Mbps']) - a['udp_upload_Mbps'].append(row['result']['udp-reverse']['Mbps']) - a['jitter'].append(row['result']['udp']['jitter_ms']) + if 'tcp' in row['result']: + a['tcp_upload_Mbps'].append( + row['result']['tcp']['received_Mbps'] + ) + if 'tcp-reverse' in row['result']: + a['tcp_download_Mbps'].append( + row['result']['tcp-reverse']['received_Mbps'] + ) + if 'udp' in row['result']: + a['udp_download_Mbps'].append(row['result']['udp']['Mbps']) + a['jitter_download'].append(row['result']['udp']['jitter_ms']) + if 'udp-reverse' in row['result']: + a['udp_upload_Mbps'].append(row['result']['udp-reverse']['Mbps']) + a['jitter_upload'].append(row['result']['udp-reverse']['jitter_ms']) a['tx_power'].append(row['result']['tx_power']) - a['frequency'].append(row['result']['frequency']) - a['channel_bitrate'].append(row['result']['bitrate']) + a['frequency'].append(row['result']['frequency']*1e-3) + if 'bitrate' in row['result']: + a['channel_bitrate'].append(row['result']['bitrate']) a['signal_quality'].append(row['result']['signal_mbm']+130) ap = self._ap_names.get( row['result']['ssid'].upper(), @@ -338,6 +345,9 @@ def _add_inner_title(self, ax, title, loc, size=None, **kwargs): return at def _plot(self, a, key, title, gx, gy, num_x, num_y): + if key not in a: + logger.info("Skipping {} due to insufficient data".format(key)) + return logger.debug('Plotting: %s', key) pp.rcParams['figure.figsize'] = ( self._image_width / 300, self._image_height / 300 From 7258a416fdc46a6dd166972fc6281e3973cd3f96 Mon Sep 17 00:00:00 2001 From: DL6ER Date: Thu, 10 Dec 2020 23:19:55 +0100 Subject: [PATCH 06/12] Update Dockerfile to use patched version of libnl Signed-off-by: DL6ER --- Dockerfile | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index fb67fb7..b125740 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,22 +6,26 @@ ARG repo_ref USER root RUN apt-get update && \ - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + DEBIAN_FRONTEND=noninteractive \ + apt-get install -y --no-install-recommends \ iperf3 \ gcc \ git \ pulseaudio-utils \ python3 \ python3-dev \ - python3-matplotlib \ python3-pip \ - python3-scipy \ python3-setuptools \ - python3-wheel \ python3-wxgtk4.0 \ - wireless-tools && \ - pip3 install \ - iperf3 + wireless-tools \ + && rm -rf /var/lib/apt/lists/* + +RUN pip3 install iperf3 matplotlib scipy wheel + +# Install libnl from DL6ER's fork because the python3.6+ +# compatibility fixes weren't included in the upstream +# project in 12/2020 +RUN pip3 install --upgrade --user git+https://github.com/DL6ER/libnl COPY . /app From 98c1a014cfa0adf489d9642c0a04cf4e55c32195 Mon Sep 17 00:00:00 2001 From: DL6ER Date: Thu, 10 Dec 2020 23:37:55 +0100 Subject: [PATCH 07/12] Allow starting wifi-survey without any mandatory arguments. Missing items will be asked for in an interactive manner. Signed-off-by: DL6ER --- README.rst | 23 ++++--- wifi_survey_heatmap/collector.py | 8 +-- wifi_survey_heatmap/libnl.py | 31 +++++---- wifi_survey_heatmap/ui.py | 105 +++++++++++++++++++++++++++---- 4 files changed, 126 insertions(+), 41 deletions(-) diff --git a/README.rst b/README.rst index a48981c..429c7cf 100644 --- a/README.rst +++ b/README.rst @@ -68,20 +68,19 @@ Performing a Survey The survey tool (``wifi-survey``) must be run as root or via ``sudo`` in order to use iwconfig/iwlist. -First connect to the network that you want to survey. Then, run ``sudo wifi-survey INTERFACE PNG Title`` where: +First connect to the network that you want to survey. Then, run ``sudo wifi-survey`` where: -* ``INTERFACE`` is the name of your Wireless interface (e.g. ``wlp3s0``) -* ``PNG`` 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 `_ 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 Date: Thu, 10 Dec 2020 23:42:24 +0100 Subject: [PATCH 08/12] Store used image filename in JSON file so wifi-heatmaps works with TITLE alone. This can still be overwritten when explicitly specifying an image. Signed-off-by: DL6ER --- README.rst | 4 ++-- wifi_survey_heatmap/heatmap.py | 35 +++++++++++++++++++++++++--------- wifi_survey_heatmap/ui.py | 10 ++++++++-- 3 files changed, 36 insertions(+), 13 deletions(-) diff --git a/README.rst b/README.rst index 429c7cf..2f2c4bc 100644 --- a/README.rst +++ b/README.rst @@ -112,7 +112,7 @@ Inside Docker, however, this becomes quite a bit more difficult. Currently Pulse 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. +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. 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. @@ -161,7 +161,7 @@ Note that running with ``--net="host"`` and ``--privileged`` is required in orde Heatmap +++++++ -``docker run -it --rm -v $(pwd):/pwd -w /pwd jantman/python-wifi-survey-heatmap:23429a4 wifi-heatmap floorplan.png DeckTest`` +``docker run -it --rm -v $(pwd):/pwd -w /pwd jantman/python-wifi-survey-heatmap:23429a4 wifi-heatmap Example`` iperf3 server +++++++++++++ diff --git a/wifi_survey_heatmap/heatmap.py b/wifi_survey_heatmap/heatmap.py index 78cee04..fd9bfbe 100644 --- a/wifi_survey_heatmap/heatmap.py +++ b/wifi_survey_heatmap/heatmap.py @@ -153,7 +153,6 @@ def __init__( self._ap_names = { x.upper(): y for x, y in json.loads(fh.read()).items() } - self._image_path = image_path self._layout = None self._image_width = 0 self._image_height = 0 @@ -165,12 +164,24 @@ def __init__( self._title += '.json' self._ignore_ssids = ignore_ssids logger.debug( - 'Initialized HeatMapGenerator; image_path=%s title=%s', - self._image_path, self._title + 'Initialized HeatMapGenerator; title=%s', + self._title ) with open(self._title, 'r') as fh: self._data = json.loads(fh.read()) - logger.info('Loaded %d measurement points', len(self._data)) + if 'survey_points' not in self._data: + logger.error('No survey points found in {}'.format(self._title)) + exit() + logger.info('Loaded %d survey points', + len(self._data['survey_points'])) + + # Try to load image from JSON if not overwritten + if image_path is None: + if 'img_path' not in self._data: + logger.error('No image path found in {}'.format(self._title)) + exit(1) + self._image_path = self._data['img_path'] + self.thresholds = {} if thresholds is not None: logger.info('Loading thresholds from: %s', thresholds) @@ -180,7 +191,7 @@ def __init__( def load_data(self): a = defaultdict(list) - for row in self._data: + for row in self._data['survey_points']: a['x'].append(row['x']) a['y'].append(row['y']) a['channel'].append(row['result']['channel']) @@ -196,8 +207,10 @@ def load_data(self): a['udp_download_Mbps'].append(row['result']['udp']['Mbps']) a['jitter_download'].append(row['result']['udp']['jitter_ms']) if 'udp-reverse' in row['result']: - a['udp_upload_Mbps'].append(row['result']['udp-reverse']['Mbps']) - a['jitter_upload'].append(row['result']['udp-reverse']['jitter_ms']) + a['udp_upload_Mbps'].append( + row['result']['udp-reverse']['Mbps']) + a['jitter_upload'].append( + row['result']['udp-reverse']['jitter_ms']) a['tx_power'].append(row['result']['tx_power']) a['frequency'].append(row['result']['frequency']*1e-3) if 'bitrate' in row['result']: @@ -258,7 +271,7 @@ def _channel_to_signal(self): """ # build a dict of frequency (GHz) to list of quality values channels = defaultdict(list) - for row in self._data: + for row in self._data['survey_points']: for scan in row['result']['scan_results']: ssid = row['result']['scan_results'][scan]['ssid'] if ssid in self._ignore_ssids: @@ -348,6 +361,9 @@ def _plot(self, a, key, title, gx, gy, num_x, num_y): if key not in a: logger.info("Skipping {} due to insufficient data".format(key)) return + if not len(a['x']) == len(a['y']) == len(a[key]): + logger.info("Skipping {} because data has holes".format(key)) + return logger.debug('Plotting: %s', key) pp.rcParams['figure.figsize'] = ( self._image_width / 300, self._image_height / 300 @@ -442,7 +458,8 @@ def parse_args(argv): p.add_argument('-c', '--cmap-name', type=str, dest='cname', action='store', default="RdYlBu_r", help='If specified, a valid matplotlib colormap name.') - p.add_argument('IMAGE', type=str, help='Path to background image') + p.add_argument('-p', '--picture', dest='IMAGE', type=str, action='store', + default=None, help='Path to background image') p.add_argument( 'TITLE', type=str, help='Title for survey (and data filename)' ) diff --git a/wifi_survey_heatmap/ui.py b/wifi_survey_heatmap/ui.py index e260ef3..9615d0e 100644 --- a/wifi_survey_heatmap/ui.py +++ b/wifi_survey_heatmap/ui.py @@ -201,7 +201,10 @@ def _load_file(self, fpath): with open(fpath, 'r') as fh: raw = fh.read() data = json.loads(raw) - for point in data: + if 'survey_points' not in data: + logger.error('Trying to load incompatible JSON file') + exit(1) + for point in data['survey_points']: p = SurveyPoint(self, point['x'], point['y']) p.set_result(point['result']) p.set_is_finished() @@ -424,8 +427,11 @@ def _ding(self): subprocess.call([self.parent.ding_command, self.parent.ding_path]) def _write_json(self): + # Only store finished survey points + survey_points = [p.as_dict for p in self.survey_points if p.is_finished] + res = json.dumps( - [x.as_dict for x in self.survey_points], + {'img_path': self.img_path, 'survey_points': survey_points}, cls=SafeEncoder, indent=2 ) with open(self.data_filename, 'w') as fh: From 7f570e40d03123659c14ce479defcf18eb796739 Mon Sep 17 00:00:00 2001 From: DL6ER Date: Thu, 10 Dec 2020 23:48:09 +0100 Subject: [PATCH 09/12] Add optional argument --contours N to draw N contours in the image (with inline labels) Signed-off-by: DL6ER --- wifi_survey_heatmap/heatmap.py | 56 +++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/wifi_survey_heatmap/heatmap.py b/wifi_survey_heatmap/heatmap.py index fd9bfbe..798e695 100644 --- a/wifi_survey_heatmap/heatmap.py +++ b/wifi_survey_heatmap/heatmap.py @@ -51,6 +51,7 @@ from matplotlib.offsetbox import AnchoredText from matplotlib.patheffects import withStroke from matplotlib.font_manager import FontManager +from matplotlib.colors import ListedColormap import matplotlib @@ -144,7 +145,7 @@ class HeatMapGenerator(object): } def __init__( - self, image_path, title, showpoints, cname, ignore_ssids=[], aps=None, + self, image_path, title, showpoints, cname, contours, ignore_ssids=[], aps=None, thresholds=None ): self._ap_names = {} @@ -159,7 +160,8 @@ def __init__( self._corners = [(0, 0), (0, 0), (0, 0), (0, 0)] self._title = title self._showpoints = showpoints - self._cname = cname + self._cmap = self.get_cmap(cname) + self._contours = contours if not self._title.endswith('.json'): self._title += '.json' self._ignore_ssids = ignore_ssids @@ -189,6 +191,23 @@ def __init__( self.thresholds = json.loads(fh.read()) logger.debug('Thresholds: %s', self.thresholds) + def get_cmap(self, cname): + multi_string = cname.split('//') + if len(multi_string) == 2: + cname = multi_string[0] + steps = int(multi_string[1]) + N = 256 + colormap = cm.get_cmap(cname, N) + newcolors = colormap(np.linspace(0, 1, N)) + rgba = np.array([0, 0, 0, 1]) + interval = int(N/steps) if steps > 0 else 0 + for i in range(0,N,interval): + newcolors[i] = rgba + print(newcolors) + return ListedColormap(newcolors) + else: + return pp.get_cmap(cname) + def load_data(self): a = defaultdict(list) for row in self._data['survey_points']: @@ -368,7 +387,8 @@ def _plot(self, a, key, title, gx, gy, num_x, num_y): pp.rcParams['figure.figsize'] = ( self._image_width / 300, self._image_height / 300 ) - pp.title(title) + fig, ax = pp.subplots() + ax.set_title(title) if 'min' in self.thresholds.get(key, {}): vmin = self.thresholds[key]['min'] logger.debug('Using min threshold from thresholds: %s', vmin) @@ -394,36 +414,39 @@ def _plot(self, a, key, title, gx, gy, num_x, num_y): # (avoids interpolation artifacts) z = numpy.ones((num_y, num_x))*vmin # Render the interpolated data to the plot - pp.axis('off') + ax.axis('off') # begin color mapping - - cmap = pp.get_cmap(self._cname) norm = matplotlib.colors.Normalize(vmin=vmin, vmax=vmax, clip=True) - mapper = cm.ScalarMappable(norm=norm, cmap=cmap) + mapper = cm.ScalarMappable(norm=norm, cmap=self._cmap) # end color mapping - image = pp.imshow( + image = ax.imshow( z, extent=(0, self._image_width, self._image_height, 0), alpha=0.5, zorder=100, - cmap=cmap, vmin=vmin, vmax=vmax + cmap=self._cmap, vmin=vmin, vmax=vmax ) - cbar = pp.colorbar(image) + if self._contours is not None: + CS = ax.contour(z, colors='k', linewidths=1, levels=self._contours, + extent=(0, self._image_width, self._image_height, 0), + alpha=0.3, zorder=150, origin='upper') + ax.clabel(CS, inline=1, fontsize=6) + cbar = fig.colorbar(image) # Print only one ytick label when there is only one value to be shown if vmin == vmax: cbar.set_ticks([vmin]) - pp.imshow(self._layout, interpolation='bicubic', zorder=1, alpha=1) + ax.imshow(self._layout, interpolation='bicubic', zorder=1, alpha=1) labelsize = FontManager.get_default_size() * 0.4 if(self._showpoints): # begin plotting points for idx in range(0, len(a['x'])): if (a['x'][idx], a['y'][idx]) in self._corners: continue - pp.plot( + ax.plot( a['x'][idx], a['y'][idx], marker='o', markeredgecolor='black', markeredgewidth=1, markerfacecolor=mapper.to_rgba(a[key][idx]), markersize=6 ) - pp.text( + ax.text( a['x'][idx], a['y'][idx] - 30, a['ap'][idx], fontsize=labelsize, horizontalalignment='center' @@ -455,9 +478,12 @@ def parse_args(argv): 'a string to label each measurement with, showing ' 'which AP it was connected to. Useful when doing ' 'multi-AP surveys.') - p.add_argument('-c', '--cmap-name', type=str, dest='cname', action='store', + p.add_argument('-c', '--cmap', type=str, dest='CNAME', action='store', default="RdYlBu_r", help='If specified, a valid matplotlib colormap name.') + p.add_argument('-n', '--contours', type=int, dest='N', action='store', + default=None, + help='If specified, N contour lines will be added to the graphs') p.add_argument('-p', '--picture', dest='IMAGE', type=str, action='store', default=None, help='Path to background image') p.add_argument( @@ -510,7 +536,7 @@ def main(): showpoints = True if args.showpoints > 0 else False HeatMapGenerator( - args.IMAGE, args.TITLE, showpoints, args.cname, + args.IMAGE, args.TITLE, showpoints, args.CNAME, args.N, ignore_ssids=args.ignore, aps=args.aps, thresholds=args.thresholds ).generate() From e549a8e9f8779e01234428091e055caa2dfe8743 Mon Sep 17 00:00:00 2001 From: DL6ER Date: Thu, 10 Dec 2020 23:52:39 +0100 Subject: [PATCH 10/12] Ensure survey points are drawn on top of everything else when they are used and that contour lines are omitted for uniform data. Signed-off-by: DL6ER --- wifi_survey_heatmap/heatmap.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/wifi_survey_heatmap/heatmap.py b/wifi_survey_heatmap/heatmap.py index 798e695..3cdf7ad 100644 --- a/wifi_survey_heatmap/heatmap.py +++ b/wifi_survey_heatmap/heatmap.py @@ -239,10 +239,7 @@ def load_data(self): row['result']['ssid'].upper(), row['result']['ssid'] ) - if row['result']['frequency'] < 5000: - a['ap'].append(ap + '_2.4GHz') - else: - a['ap'].append(ap + '_5GHz') + a['ap'].append(ap + ' ({0:.1f} GHz)'.format(1e-3*int(row['result']['frequency']))) return a def _load_image(self): @@ -425,15 +422,20 @@ def _plot(self, a, key, title, gx, gy, num_x, num_y): alpha=0.5, zorder=100, cmap=self._cmap, vmin=vmin, vmax=vmax ) - if self._contours is not None: + + # Draw contours if requested and meaningful in this plot + if self._contours is not None and vmin != vmax: CS = ax.contour(z, colors='k', linewidths=1, levels=self._contours, extent=(0, self._image_width, self._image_height, 0), alpha=0.3, zorder=150, origin='upper') ax.clabel(CS, inline=1, fontsize=6) cbar = fig.colorbar(image) + # Print only one ytick label when there is only one value to be shown if vmin == vmax: cbar.set_ticks([vmin]) + + # Draw floorplan itself to the lowest layer with full opacity ax.imshow(self._layout, interpolation='bicubic', zorder=1, alpha=1) labelsize = FontManager.get_default_size() * 0.4 if(self._showpoints): @@ -442,7 +444,7 @@ def _plot(self, a, key, title, gx, gy, num_x, num_y): if (a['x'][idx], a['y'][idx]) in self._corners: continue ax.plot( - a['x'][idx], a['y'][idx], + a['x'][idx], a['y'][idx], zorder=200, marker='o', markeredgecolor='black', markeredgewidth=1, markerfacecolor=mapper.to_rgba(a[key][idx]), markersize=6 ) From 2a43eb4d3a9a81500890324a44e576b32373963e Mon Sep 17 00:00:00 2001 From: DL6ER Date: Sun, 20 Dec 2020 13:03:49 +0100 Subject: [PATCH 11/12] Only try to plot for what we have data in the JSON file Signed-off-by: DL6ER --- wifi_survey_heatmap/heatmap.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/wifi_survey_heatmap/heatmap.py b/wifi_survey_heatmap/heatmap.py index 3cdf7ad..7b2bd38 100644 --- a/wifi_survey_heatmap/heatmap.py +++ b/wifi_survey_heatmap/heatmap.py @@ -275,9 +275,13 @@ def generate(self): gx, gy = np.meshgrid(x, y) gx, gy = gx.flatten(), gy.flatten() for k, ptitle in self.graphs.items(): - self._plot( - a, k, '%s - %s' % (self._title, ptitle), gx, gy, num_x, num_y - ) + try: + self._plot( + a, k, '%s - %s' % (self._title, ptitle), gx, gy, num_x, num_y + ) + except: + logger.warning('Cannot create {} plot: ' + 'insufficient data'.format(k)) def _channel_to_signal(self): """ From 39f4d984225019f69464a282d1e148718a75516d Mon Sep 17 00:00:00 2001 From: DL6ER Date: Sun, 20 Dec 2020 15:58:18 +0100 Subject: [PATCH 12/12] Allow iperf3 server to be specified as ip:port Signed-off-by: DL6ER --- wifi_survey_heatmap/collector.py | 15 +++++++++++---- wifi_survey_heatmap/ui.py | 3 ++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/wifi_survey_heatmap/collector.py b/wifi_survey_heatmap/collector.py index 6730fe0..1c3f772 100644 --- a/wifi_survey_heatmap/collector.py +++ b/wifi_survey_heatmap/collector.py @@ -62,11 +62,18 @@ def __init__(self, server_addr, duration, scanner, scan=True): def run_iperf(self, udp=False, reverse=False): client = iperf3.Client() client.duration = self._duration - client.server_hostname = self._iperf_server - client.port = 5201 + + server_parts = self._iperf_server.split(":") + if len(server_parts) == 2: + client.server_hostname = server_parts[0] + client.port = int(server_parts[1]) + else: + client.server_hostname = self._iperf_server + client.port = 5201 # substitute some default port + client.protocol = 'udp' if udp else 'tcp' client.reverse = reverse - logger.debug( + logger.info( 'Running iperf to %s; udp=%s reverse=%s', self._iperf_server, udp, reverse ) @@ -74,7 +81,7 @@ def run_iperf(self, udp=False, reverse=False): res = client.run() if res.error is None: break - logger.error('iperf error: %s; retrying', res.error) + logger.error('iperf error %s; retrying', res.error) logger.debug('iperf result: %s', res) return res diff --git a/wifi_survey_heatmap/ui.py b/wifi_survey_heatmap/ui.py index 9615d0e..5023e2b 100644 --- a/wifi_survey_heatmap/ui.py +++ b/wifi_survey_heatmap/ui.py @@ -471,7 +471,8 @@ def run_iperf(self, count, udp, reverse): # else this is an error if tmp.error.startswith('unable to connect to server'): self.warn( - 'ERROR: Unable to connect to iperf server. Aborting.' + 'ERROR: Unable to connect to iperf server at {}. Aborting.'. + format(self.collector._iperf_server) ) return None if self.YesNo('iperf error: %s. Retry?' % tmp.error):