Tekniktips

Tekniktips

Hur bra är ditt Wi-Fi egentligen?

Är du trött på att skylla på kosmisk strålning varje gång du ska streama från netflix eller köra nätverkstunga tester? I det här inlägget bjuder jag på ett primitivt gränssnitt som kan utökas eller skalas ner för att passa olika behov – allt i diagnostikens namn i jakten på ett stabilt Wi-Fi.

Stor fråga – många svar

Vad innebär det att diagnostisera en Wi-Fi anslutning? Det är en bred fråga med många svar. Ibland behövs extremt detaljerade och långtgående analyser för att säkerställa huruvida anslutningen är “bra” eller “dålig” men ofta räcker det med en fingervisning, att ta tempen på nätverket. Lite längre ner i det här inlägget finns det kod som hjälper främst med det sistnämnda men som kan utökas för att även täcka in det förstnämnda.

Gränssnitt

Koden, som är fri att använda, bygger på att du har en Androidlur (Android 7.0 eller högre) kopplad till en dator med Linux-distro och Python (2.7 eller högre) installerat. Se till att ​aktivera USB-debugging​ på luren också.
Som ni kan se använder jag ganska enkla metoder för att ta reda på informationen jag är ute efter. Därefter blir det en aning mer komplicerat med användandet av reguljära uttryck och nästlade loopar. Jag ska försöka kort sammanfatta helheten i koden, medan detaljerna kring exakt vad det är jag gör, lämnar jag som en övning åt läsaren!

Kod

Här kommer koden! Lite längre ner ger jag en övergriplig beskrivning över vad som pågår. Värt att notera är att jag har kört kommandona mot en relativt modern Samsung-enhet. Kan inte lova att det funkar för alla möjliga Android-enheter!

import re
import subprocess


class AdbInterface:
    """
    Android Debug Bridge (ADB) interface. 
    Communicates with Android device through 
    underlying linux terminal.
    """
    
    # Serial number of device
    SERIAL = None
    
    def __init__(self, device_serial_nbr):
        self.SERIAL = device_serial_nbr
        pass
    
    def shell(self, android_shell_command):
        terminal_command = "adb -s %s shell \"%s\""
                           % (self.SERIAL, 
                              android_shell_command)
        proc = subprocess.Popen(terminal_command, 
                                stdout=subprocess.PIPE, 
                                shell=True, 
                                stderr=subprocess.PIPE)
        (out, err) = proc.communicate()
        if proc.returncode not in [0, 1]:
            raise Exception("Error executing command"
                            " '%s'\n exit code: %d, "
                            "stderr: %s, output: %s" %
                            (terminal_command, 
                             proc.returncode, 
                             err, out))
        return out


class WifiDiagnosticTool:

    # Easy to understand diagnostic labels
    SCORE_0 = "Horrible"
    SCORE_1 = "Very bad"
    SCORE_2 = "Bad"
    SCORE_3 = "OK"
    SCORE_4 = "Good"
    SCORE_5 = "Very good"
    
    # Dictionary keys to keep track of ping measurements
    PING_MIN = "min"
    PING_MAX = "max"
    PING_AVG = "avg"
    PING_JIT = "jit"
    PING_LOS = "los"
    
    # Default thresholds for RSSI values ranging 
    # from best to worst
    RSSI_DEFAULT_THRESHOLDS = [-30, -67, -70, -80, -90]
    
    # Makes it easier to iterate through measurements
    # and assigning scores
    SCORE_LABELS = [SCORE_5, SCORE_4, SCORE_3, \
                    SCORE_2, SCORE_1, SCORE_0]
    
    def __init__(self, device_serial_nbr):
        self.adb = AdbInterface(device_serial_nbr)
        
    def logWiFiDiagnosticForDevice(self, n_packets):
        """
        Presents many relevant measurements 
        and values regarding Wi-Fi connectivity 
        and stability
        :param n_packets: The number of packets to do 
                          PING tests with
        """
    
        ssid = self.getWiFiSSID()
        ap_mac = self.getAccessPointMac()
        ok, status = self.isWiFiOK()
        rssi = self.getRSSIValue()
        mac = self.getMACofDevice()
        response, speeds = self.ping(n_packets)
        rssi_score = self.scoreWiFiSignalStrength(rssi)
        speed_score_max, speed_score_min, \
        speed_score_avg, jitter_score = self.getPingScores(speeds)

        print("\nWiFi diagnostic: "
              "\n\tDevice connected to WiFi '%s' "
              "with following properties:\n\t\t"
              "- Device MAC: %s\n\t\t"
              "- Access point MAC: %s\n\t\t"
              "- Signal strength: %s\n\t\t"
              "- WiFi is OK: %s\n\t\t"
              "- Ping # of packets: %d\n\t\t"
              "- Packet loss: %d%%\n\t\t"
              "- Ping average transfer speed (ms): %s\n\t\t"
              "- Ping MAX transfer speed (ms): %s\n\t\t"
              "- Ping MIN transfer speed (ms): %s\n\t\t"
              "- Ping jitter (ms): %s\n\t\t"
              "- Ping response: %s\n" %
              (ssid, mac, ap_mac, rssi_score, ok, n_packets, 
               speeds[self.PING_LOS], speed_score_avg,
               speed_score_max, speed_score_min, 
               jitter_score, response))
        
    def getWiFiSSID(self):
        """
        Gets the name of the Wi-Fi network the device is
        connected to.
        """
        cmd = "dumpsys netstats | grep -E 'iface=wlan.*networkId'"
        response = self.adb.shell(cmd)
        match = re.search("networkId=\"(.*)\"", response)
        if match:
            return match.group(1)
        else:
            # Device is not connected to a Wi-Fi 
            # network (could be disabled?)
            return None  

    def ping(self, n_packets, destination="8.8.8.8"):
        """
        Pings an address and saves time measurements 
        as well as packet loss
        :param n_packets: How many packets to send to 
                          destination address
        :param destination: The address to send 
                            TCMP-packets to
        :return: The actual terminal response as well as 
                 a dictionary containing the speed measurements
        """
        command = "ping -c%s %s" % (n_packets, destination)
        # Easier to use with RegExp later if we de-capitalize
        response = self.adb.shell(command).lower()  
        speeds = {
            self.PING_MIN: -1,
            self.PING_AVG: -1,
            self.PING_MAX: -1,
            self.PING_JIT: -1,
            self.PING_LOS: -1
                }
        match = re.search("rtt min/avg/max/mdev = 
                           (.*?)/(.*?)/(.*?)/(.*?) ms", response)
        loss = 100
        loss_match = re.search("([0-9]+)% packet loss", response)
        if loss_match:
            loss = int(loss_match.group(1))
        try:
            speeds[self.PING_LOS] = loss
            # match.group() will raise AttributeError 
            # if the packet loss is 100% 
            # (no measurements in response)
            speeds[self.PING_MIN] = float(match.group(1))
            speeds[self.PING_AVG] = float(match.group(2))
            speeds[self.PING_MAX] = float(match.group(3))
            speeds[self.PING_JIT] = float(match.group(4))
        except AttributeError:
            print("Unable to find any speed " 
                  "statistics for device.\n"
                  "Packet loss: %d \nADB Response: [%s]" % 
                   (loss, response.strip()))
            pass
        return response, speeds

    def getWifiInfo(self):
        """
        Helper function that groups all relevant Wi-Fi information
        """
        cmd = "dumpsys wifi | grep 'mWifiInfo SSID'"
        return self.adb.shell(cmd)

    def getAccessPointMac(self):
        """
        The MAC address of the access point 
        (of the Wi-Fi device is connected to)
        """
        match = re.search("BSSID: (.*?),", self.getWifiInfo())
        if match:
            return match.group(1).strip()
        return None

    def getMACofDevice(self):
        """Gets the MAC of the device"""
        mac = self.adb.shell("cat /sys/class/net/wlan0/address")
        # On some devices root permission is 
        # needed to read from /sys/.
        if not mac or "Permission denied" in mac:
            match = re.search("MAC: (.*?),", self.getWifiInfo())
            if match:
                mac = match.group(1)
            else:
                mac = “N\A” # Unable to get MAC address. 
                            # Perhaps a policy restricting reads?
        return mac.strip()

    def getRSSIValue(self):
        """
        Gets the RSSI value of the phone's Wi-Fi 
        module and the connected network
        """
        response = self.getWifiInfo()
        match = re.search("RSSI: (.*?),", response)
        if match:
            return int(match.group(1))
            # NOTE: If the value is less than or 
            # equal -127, then Wi-Fi connections
            # are either disabled on phone, or the Wi-Fi 
            # access point is too far away (physically)
        return None

    def scoreWiFiSignalStrength(self, rssi_value, \
                                rssi_score_range=\
                                RSSI_DEFAULT_THRESHOLDS):
        """
        Checks the measured RSSI signal strength between 
        device and Wi-Fi access point and gives it a score.
        Note: The optimal score is 0. The lower the score, 
        the worse the signal is.
        :param rssi_value: The value to score
        :param rssi_score_range: An arbitrary range of numbers 
                                 that should be sorted from 
                                 best to worst value
        :return: The actual RSSI value and a string which 
                 represents a score/ranking/interpretation 
                 of the signal
        """
        
        for i in range(len(rssi_score_range)):
            threshold = rssi_score_range[i]
            score = self.SCORE_LABELS[i]
            if rssi_value >= threshold:
                return "%s -- %s" % (rssi_value, score)
        # Worse than the worst provided!
        return "%s -- %s" % (rssi_value, self.SCORE_0)  
    
    def isWiFiOK(self):
        """Checks if the Wi-Fi connection is established """
        cmd = "dumpsys wifi | grep 'mNetworkInfo'"
        response = self.adb.shell(cmd)
        return "CONNECTED/CONNECTED" in response, response

    def scorePingSpeed(self, value, base=20, factor=2.5):
        """
        Gives a speed measurement a score. Algorithm evaluates 
        the given value agains a "base" value (i.e. the "best" or
        minimum value needed for the highest score. If value 
        is higher, then it increases the reference value (to check
        against) as well as incrementing the index of the eventual 
        score. This is repeated for all iterations
        until an appropriate score is found (or the value is so 
        horrible that it falls "out of range").

        :param value: the value to give a score to
        :param base: the minimum value that will be multiplied
                     by factor each iteration
        :param factor: the factor to multiply parameter 
                       'base' with each iteration
        :return: A scoring of the given measurement
        """

        if value < 0 or not isinstance(value, float): 
            return "N/A --> invalid value '%s'" % value
        ref = base

        # Don't check last index in loop (<- lowest score anyway)
        for i in range(len(self.SCORE_LABELS) - 1):  
            if value <= ref: 
                return self.SCORE_LABELS[i] 
            ref *= factor 
        return self.SCORE_0 

def getPingScores(self, speeds): 
        """ 
        Given response from shell ping command, 
        give each value a score. 
        High values -> low scores, 
        Higher scores -> better results
        :param speeds: a dictionary where each key-value 
        mapping corresponds to ping response results.
        :return: score strings
        """
        avg_speed = speeds[self.PING_AVG]
        min_speed = speeds[self.PING_MIN]
        max_speed = speeds[self.PING_MAX]
        jitter = speeds[self.PING_JIT]

        speed_score_max = str(max_speed) + ", " + \
                          self.scorePingSpeed(max_speed)
        speed_score_min = str(min_speed) + ", " + \
                          self.scorePingSpeed(min_speed)
        speed_score_avg = str(avg_speed) + ", " + \
                          self.scorePingSpeed(avg_speed)
        jitter_score = str(jitter) + ", " + \
                       self.scorePingSpeed(jitter, 3, 3)

        return speed_score_max, speed_score_min, \
               speed_score_avg, jitter_score

ADB

Klassen ​AdbInterface är inte nödvändigtvis det bästa verktyget för att kommunicera med enheten, men det är banne mig smidigt och enkelt att implementera! Metoden ​shell​ tar emot ett kommando som vi vill ska köras på telefonen och exekverar det i ett virtuelt linuxskal på datorn som är kopplatt till enheten (i den här implementationen begränsar vi oss till adb shell, men du kan ju utöka gränssnittet ganska lätt).

Notera att om vi får returkoderna 0 och 1 är goda tecken – returkoden 1 innebär bara att kommandot som exekverats inte har ett returvärde. I den här implementationen är det inte aktuellt men jag har lämnat kvar kollen så att du inte får psykbryt när du väljer att göra något eget av det hela!

Gränssnittet kan endast kommunicera med en enhet i taget. Vilken enhet som är aktuell får du själv ta reda på genom att köra kommandot “adb devices -l” och kopiera serienumret (en lång serie av hexadecimala tecken). Det är detta serienumret som variabeln ​SERIAL ska ha som värde!

Diagnostikverktyg

Själva kronan på verket i klassen ​WifiDiagnosticTool är metoden ​logWiFiDiagnosticForDevice(). Om du vill ha en bred och heltäckande utsaga om din anslutnings välmående så räcker ett anrop till den här metoden gott och väl. Den kommer att kolla upp:

MAC-adresser ​för såväl din enhet som a​ccesspunkten ​ditt Wi-Fi är kopplat till. Sitter du i ett kontor med flera accesspunkter till ett och samma Wi-Fi så kan det verkligen hjälpa med att lokalisera vilken som är boven i dramat (givet att det går dåligt, så klart).

● Mätvärden som berör hastigheter, förluster och ​jitter​ i datatrafik.

● Hur såväl hastigheterna men även signalstyrkan (​RSSI-värdet​) mäter sig givet egendefinierade gränsvärden.

Betyg

Jag har i koden tre stycken “default”-uppsättningar av gränsvärden för just latency, jitter och signalstyrka. För signalstyrkan har jag valt värdena angivna i ​RSSI_DEFAULT_THRESHOLDS. För latency och jitter behöver man titta i metoden ​scorePingSpeed där ett grundvärde (högsta tillåtna värdet för det högsta betyget) multipliceras med en faktor tills det att värdet kan kategoriseras i någon av de 5 kategorierna som jag har i min kod. Själva betygen anges i variabeln ​SCORE_LABELS.

Det går alltså att se att för latency utgår jag från att allt under 20 ms i svarstid är bästa tänkbara. Därefter multiplicerar jag det här basvärdet med 2.5 i varje iteration tills det att mitt faktiska uppmätta värde hamnar under det beräknade referensvärdet (eller ja, tills det “rinner över” och det absolut sämsta betyget ges). När det gäller jitter så har jag en bas av 3 och en faktor 3 – därför att jitter ska vara lågt!

Ping

Hur får vi tag på några mätvärden till att börja med? RSSI-värden loggas av Android kontinuerligt så där kollar vi bara i enhetens databas över mätvärden. För svarstiden använder vi den gamla klassiska trotjänaren ​ping​. Du kan skicka så många paket eller “pingar” som du känner att du behöver, men personligen tycker jag att det räcker med 10 för att få en bra fingervisning. Spana in outputen från ping​ (som vi parse:ar i koden) så kanske du enklare förstår vad och varför jag gör som jag gör i metoden ping!

Reflektioner

Har jag tänkt rätt i mina RSSI-gränsvärden? Är de för snäva eller för generösa? Är 20 ms verkligen “optimalt” i svarstid? Det kanske det är om man tittar på vilken ​adress​ som jag föreslår att du pingar till. För mig är en genomsnittlig svarstid på 100 millisekunder helt okej… men det är det nog inte om du är en gamer! Frågor, kommentarer och kritik tar jag glatt emot! Hör av dig till mig på alexander.theofanous@i3tex.com

Alexander Theofanous har studerat Datateknik vid Lunds Tekniska Högskola. Efter studierna fick han anställning hos i3tex i Linköping. Alexanders nuvarande uppdrag är som testingenjör på Sectra Secure Communications där han hjälper till att automatisera tester och utveckla integrationsmiljön. Innan dess har han jobbat med att utveckla Androidapplikationer.

Läs mer om vårt tjänsteområde Mjukvaruutveckling här >>

Postad: 20 november 2018