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 diff --git a/README.rst b/README.rst index f6944cf..2f2c4bc 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,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 SERVER 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``) -* ``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 `_ 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 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: + for row in self._data['survey_points']: 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(), 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): @@ -239,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): """ @@ -251,7 +291,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: @@ -338,11 +378,18 @@ 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 + 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 ) - 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) @@ -368,36 +415,44 @@ 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) + + # 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]) - pp.imshow(self._layout, interpolation='bicubic', zorder=1, alpha=1) + + # 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): # 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], + ax.plot( + a['x'][idx], a['y'][idx], zorder=200, 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' @@ -429,10 +484,14 @@ 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('IMAGE', type=str, help='Path to background image') + 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( 'TITLE', type=str, help='Title for survey (and data filename)' ) @@ -483,7 +542,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() diff --git a/wifi_survey_heatmap/libnl.py b/wifi_survey_heatmap/libnl.py index 5d42d62..355c435 100644 --- a/wifi_survey_heatmap/libnl.py +++ b/wifi_survey_heatmap/libnl.py @@ -73,13 +73,13 @@ class Scanner(object): - def __init__(self, interface_name, scan=True): + def __init__(self, interface_name=None, scan=True): super().__init__() logger.debug( 'Initializing Scanner for interface: %s', interface_name ) - self._interface_name = interface_name + self.interface_name = interface_name self._scan = scan self.iface_data = {} @@ -87,19 +87,26 @@ def __init__(self, interface_name, scan=True): # Get all interfaces of this machine self.if_idx = None - self.update_iface_details(nl80211.NL80211_CMD_GET_INTERFACE) - names = [] + self.iface_names = self.list_all_interfaces() + + def set_interface(self, interface_name): for idx in self.iface_data: - if 'name' in self.iface_data[idx]: - names.append(self.iface_data[idx]['name']) - if self.iface_data[idx]['name'] == interface_name: - self.if_idx = idx - break + if self.iface_data[idx]['name'] == interface_name: + self.if_idx = idx + break if self.if_idx == None: logger.error("Device {0} is not a valid interface, use" - " one of {1}".format(interface_name, names)) + " one of {1}".format(interface_name, self.iface_names)) exit(1) + def list_all_interfaces(self): + self.update_iface_details(nl80211.NL80211_CMD_GET_INTERFACE) + iface_names = [] + for idx in self.iface_data: + if 'name' in self.iface_data[idx]: + iface_names.append(self.iface_data[idx]['name']) + return iface_names + def _error_handler(self, _, err, arg): """Update the mutable integer `arg` with the error code.""" arg.value = err.error @@ -289,7 +296,7 @@ def scan_all_access_points(self): # Scan for access points within reach # First get the wireless interface index. - pack = struct.pack('16sI', self._interface_name.encode('ascii'), 0) + pack = struct.pack('16sI', self.interface_name.encode('ascii'), 0) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: info = struct.unpack('16sI', fcntl.ioctl( @@ -297,7 +304,7 @@ def scan_all_access_points(self): except OSError: return logger.warning( 'Wireless interface {0}\ - does not exist.'.format(self._interface_name)) + does not exist.'.format(self.interface_name)) finally: sock.close() if_index = int(info[1]) diff --git a/wifi_survey_heatmap/ui.py b/wifi_survey_heatmap/ui.py index dc6d69a..5023e2b 100644 --- a/wifi_survey_heatmap/ui.py +++ b/wifi_survey_heatmap/ui.py @@ -44,6 +44,7 @@ import subprocess from wifi_survey_heatmap.collector import Collector +from wifi_survey_heatmap.libnl import Scanner FORMAT = "[%(asctime)s %(levelname)s] %(message)s" logging.basicConfig(level=logging.WARNING, format=FORMAT) @@ -119,6 +120,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): @@ -192,14 +194,17 @@ def __init__(self, parent): self._load_file(self.data_filename) self._duration = self.parent.duration self.collector = Collector( - self.parent.interface, self.parent.server, self._duration) + self.parent.server, self._duration, self.parent.scanner) self.parent.SetStatusText("Ready.") 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() @@ -282,7 +287,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) @@ -293,9 +298,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 @@ -314,7 +319,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 @@ -334,9 +339,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,13 +358,15 @@ 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 - if len(self.collector._iperf_server) > 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 @@ -360,15 +375,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 +391,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 @@ -413,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: @@ -454,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): @@ -472,12 +490,11 @@ def on_paint(self, event=None): class MainFrame(wx.Frame): def __init__( - self, img_path, interface, server, survey_title, scan, bssid, ding, - ding_command, duration, *args, **kw + self, img_path, server, survey_title, scan, bssid, ding, + ding_command, duration, scanner, *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 @@ -488,6 +505,7 @@ def __init__( self.ding_command = ding_command self.duration = duration self.CreateStatusBar() + self.scanner = scanner self.pnl = FloorplanPanel(self) self.makeMenuBar() @@ -515,9 +533,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, @@ -525,15 +548,14 @@ 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)' - ) + p.add_argument('-i', '--interface', dest='INTERFACE', action='store', + type=str, default=None, + help='Wireless interface name') + p.add_argument('-p', '--picture', dest='IMAGE', type=str, + default=None, help='Path to background image') + p.add_argument('-t', '--title', dest='TITLE', type=str, + default=None, help='Title for survey (and data filename)' + ) args = p.parse_args(argv) return args @@ -567,6 +589,58 @@ def set_log_level_format(level, format): logger.setLevel(level) +def ask_for_wifi_iface(app, scanner): + frame = wx.Frame(None) + title = 'Wireless interface' + description = 'Please specify the wireless interface\nto be used for your survey' + dlg = wx.SingleChoiceDialog(frame, description, title, scanner.iface_names) + if dlg.ShowModal() == wx.ID_OK: + resu = dlg.GetStringSelection() + else: + # User clicked [Cancel] + exit() + dlg.Destroy() + frame.Destroy() + + return resu + + +def ask_for_title(app): + frame = wx.Frame(None) + title = 'Title of your measurement' + description = 'Please specify a title for your measurement. This title will be used to store the results and to distinguish the generated plots' + default = 'Example' + dlg = wx.TextEntryDialog(frame, description, title) + dlg.SetValue(default) + if dlg.ShowModal() == wx.ID_OK: + resu = dlg.GetValue() + else: + # User clicked [Cancel] + exit() + dlg.Destroy() + frame.Destroy() + + return resu + + +def ask_for_floorplan(app): + frame = wx.Frame(None) + title = 'Select floorplan for your measurement' + dlg = wx.FileDialog(frame, title, + wildcard='Compatible image files (*.png, *.jpg,*.tiff, *.bmp)|*.png;*.jpg;*.tiff;*.bmp;*:PNG;*.JPG;*.TIFF;*.BMP;*.jpeg;*.JPEG', + style=wx.FD_FILE_MUST_EXIST) + if dlg.ShowModal() == wx.ID_OK: + resu = dlg.GetPath() + print(resu) + else: + # User clicked [Cancel] + exit() + dlg.Destroy() + frame.Destroy() + + return resu + + def main(): if os.getuid() != 0: logger.warning("You should run this script as root" @@ -582,13 +656,37 @@ def main(): set_log_info() app = wx.App() + + scanner = Scanner(scan=args.scan) + + # Ask for possibly missing fields + # Wireless interface + if args.INTERFACE is None: + INTERFACE = ask_for_wifi_iface(app, scanner) + else: + INTERFACE = args.INTERFACE + + # Definitely set interface at this point + scanner.set_interface(INTERFACE) + + # Floorplan image + if args.IMAGE is None: + IMAGE = ask_for_floorplan(app) + else: + IMAGE = args.IMAGE + + # Title + if args.TITLE is None: + TITLE = ask_for_title(app) + else: + TITLE = args.TITLE + frm = MainFrame( - args.IMAGE, args.INTERFACE, args.SERVER, args.TITLE, args.scan, - args.bssid, args.ding, args.ding_command, args.duration, None, - title='wifi-survey: %s' % args.TITLE, + IMAGE, args.IPERF3_SERVER, TITLE, args.scan, + args.BSSID, args.ding, args.ding_command, args.IPERF3_DURATION, + scanner, None, title='wifi-survey: %s' % args.TITLE, ) frm.Show() - frm.Maximize(True) frm.SetStatusText('%s' % frm.pnl.GetSize()) app.MainLoop()