class WifiWatch

Constants

Default_count
Default_host
Program
Version

Public Instance Methods

beautiful_times() click to toggle source

Make history lines pretty. We want the history minutes to be modulo the accounting minutes. Where possible we want to let the ping counts to be the same. Ping can drift backward by one second and forward by a few seconds each run. Overall the drift is usually forward so when we need to make an adjustment we aim for 10 seconds into the next aligned minute.

# File bin/wifi-watch, line 358
def beautiful_times()
  count = @options.count

  # Is the target count minute aligned?
  if count % 60 != 0
    return count # Not minute aligned.
  end

  # Is the target count hour aligned?
  minutes = count / 60
  if 60 % minutes != 0
    return count # Not hour aligned.
  end

  # See if we are already suitably aligned right now.
  now = Time.now
  mm = now.min
  ss = now.sec
  if mm % minutes == 0 && 1 <= ss && ss <= 50
    return count # Now is minute aligned and within minute drift limits.
  end

  # Target 10 seconds into the next aligned minute.
  short_mm = minutes - (mm % minutes)
  count = (short_mm * 60) + 10 - ss
  return count
end
commit_line() click to toggle source

If there is a current display line, commit it to history.

# File bin/wifi-watch, line 407
def commit_line()
  if @display_line
    if @ping_from
      # Debug mode show final line value as history.
      puts @display_line
    else
      # Normal mode advance to the next line leaving this line as history.
      puts
    end
    @display_line = nil
  end
end
display_line(line) click to toggle source

Rewrite the current display line or save it for final debugging result.

# File bin/wifi-watch, line 397
def display_line(line)
  @display_line = line
  unless @ping_from
    # Display this current line.
    STDOUT.write "\r"   # Move to the start of this line.
    STDOUT.write line   # Write over it.
  end
end
get_options() click to toggle source
# File bin/wifi-watch, line 85
def get_options
  options         = OpenStruct.new
  options.host    = Default_host
  options.count   = Default_count
  options.periods = 0

  opt_parser = OptionParser.new do |opt|
    opt.banner = "Usage: #{Program} [OPTIONS]"
    opt.separator ""

    opt.on("-h", "--host host", "Host to ping [#{options.host}].") do |host|
      options.host = host
    end

    opt.on("-c", "--count seconds", Integer,
           "Accounting period length [#{options.count} seconds].") do |count|
      raise "--count must be positive" unless count > 0
      options.count = count
    end

    opt.on("-p", "--periods periods", Integer,
           "Accounting periods to run [infinite].") do |periods|
      options.periods = periods
    end

    opt.on("--ping-to file",
           "Write ping output to a file for debugging.") do |ping_to|
      options.ping_to = ping_to
    end

    opt.on("--ping-from file",
           "Read ping input from a file ('-' for stdin).") do |ping_from|
      options.ping_from = ping_from
    end

    opt.on("-v", "--version", "Version.") do
      puts Version
      exit
    end

    opt.on("--help", "This message.") do
      puts
      puts opt_parser
      puts
      exit
    end

    opt.separator ""
    opt.separator "Documentation at https://github.com/jgorman/wifi-watch"

  end

  begin
    opt_parser.parse!
  rescue => e
    puts
    puts e
    puts
    puts opt_parser
    puts
    exit
  end

  return options
end
hhmm_line(time, line) click to toggle source
# File bin/wifi-watch, line 386
def hhmm_line(time, line)
  line = "#{time.strftime('%H:%M')} #{line}"
  display_line(line)
end
hhmmss_line(time, line) click to toggle source
# File bin/wifi-watch, line 391
def hhmmss_line(time, line)
  line = "#{time.strftime('%H:%M:%S')} #{line}"
  display_line(line)
end
ping_period(stdin) click to toggle source

Run ping for one accounting period.

# File bin/wifi-watch, line 152
def ping_period(stdin)

  current_mode    = nil   # Are we currently "good" or "fail"?
  mode_count      = 0     # For how long in a row?
  total_count     = 0     # Pings so far.
  totally_good    = 0     # Successful pings.
  ping_ms         = 0.0   # Latest successful ping time in milliseconds.
  packets_line    = ''    # Ping packets received summary line.
  round_trip_line = ''    # Ping round-trip milliseconds summary line.
  filter_mode     = false # Wifi login filter is active.
  line_time       = nil   # Latest line timestamp.
  first_byte      = nil   # DNS lookup complete read test byte.

  # Long DNS lookups can effectively freeze ping for a while.
  # To reproduce: turn VPN off and then on, then turn WiFi off.
  # This logic is impractical to simulate in a meaningful way
  # so we just pass the messages on for later debugging display.
  waiting_start = Time.now
  last_notified = waiting_start
  until first_byte
    begin
      first_byte = stdin.read_nonblock(1)
    rescue IO::EAGAINWaitReadable
      now = Time.now
      if now - last_notified >= 1
        hhmmss_line(now, "Waiting for dns lookup of #{@options.host} ...")
        if @ping_to
          @ping_to.puts "#{now.to_i} DNS_LOOKUP #{@display_line}"
          @ping_to.flush
        end
        last_notified = now
      end
      sleep 0.2 # Be nice to the cpu.
    rescue
      break   # Some systems throw an error at eof.
    end
  end
  if last_notified > waiting_start
    commit_line()
  end

  # Process each line of ping output.
  while true
    begin
      line = stdin.gets
    rescue
      break   # Some systems throw an error at eof.
    end
    break unless line

    line.chomp!
    if first_byte
      line = first_byte + line
      first_byte = nil
    end

    if @ping_from
      # Read from the debugging source file up to the end of this ping run.
      if line == "END_OF_PING"
        break # End this ping accounting period run.
      end
      # Simulate the past by loading the effective time for this line.
      time, line = line.split(" ", 2)
      line_time = Time.at(time.to_i)
    else
      line_time = Time.now
    end

    if @ping_to
      # Capture this ping output line with the timestamp for later debugging.
      @ping_to.puts "#{line_time.to_i} #{line}"
      @ping_to.flush
    end

    # DNS failure modes.
    if line =~ /cannot resolve/
      # ping: cannot resolve ns.google.com: Unknown host
      hhmmss_line(line_time, line)
      sleep 1 unless @ping_from # Speed up testing.
      @dns_failing = true
    elsif line =~ /DNS_LOOKUP (.*)/
      # Waiting for dns lookup of ns.google.com ...
      display_line($1)
      @dns_failing = true
    elsif @dns_failing
      # If DNS was previously failing, pop to a fresh line.
      commit_line()
      @dns_failing = false
    end

    # This indicates a ping success or failure line. Nil means neither.
    new_mode = nil

    # Parse the line.
    if line =~ /PING/
      #
      # The first ping output line shows the destination host and ip.
      #
      # PING ns.google.com (216.239.32.10): 56 data bytes
      #
      unless @shown_header
        puts line # Show the destination name and ip.
        puts
        puts "Time  Ping  Run Mode Failed Round Trip"
        puts "----- ---- ---- ---- ------ ----------"
        @shown_header = true # Only show these once per wifi-watch.
      end

    elsif line =~ /bytes from/
      if line =~ /time=(\S+)/
        #
        # Success! Record the round trip time.
        #
        # 64 bytes from 104.27.146.148: icmp_seq=48 ttl=61 time=24.780 ms
        #
        ping_ms = $1.to_f
        new_mode  = 'good'
        if filter_mode
          commit_line() # Keep the filter message line.
          filter_mode = false
        end
      else
        #
        # Here the wifi login has timed out and is blocking packets.
        # Multi line ping output shows both the timeout and filter message
        # lines plus some additional diagnostic lines that we ignore.
        #
        # Request timeout for icmp_seq 5
        # 92 bytes from 10.128.128.128: Communication prohibited by filter
        # Vr HL TOS Len ID Flg off TTL Pro cks Src Dst
        # 4 5 00 5400 6dea 0 0000 40 01 21dd 10.57.228.249 104.27.147.148
        #
        unless filter_mode
          commit_line() # Keep the last unfiltered status line.
          filter_mode = true
        end
        hhmmss_line(line_time, line)
      end

    elsif line =~ /timeout/
      # Request timeout for icmp_seq 3
      new_mode  = 'fail'

    elsif line =~ /transmitted/
      # 5 packets transmitted, 5 packets received, 0.0% packet loss
      packets_line = line

    elsif line =~ /round-trip/
      # round-trip min/avg/max/stddev = 0.063/0.082/0.122/0.022 ms
      round_trip_line = line
    end

    if new_mode
      # Update the counts with success or failure.
      if new_mode != current_mode
        current_mode  = new_mode
        mode_count    = 0
      end
      mode_count    += 1
      totally_good  += 1 if current_mode == 'good'
      total_count   += 1
      unless filter_mode
        show_stats(line_time, total_count, totally_good,
                  current_mode, mode_count, ping_ms)
      end
    end

  end

  if @ping_to
    # Insert a ping run separator line into the debugging file.
    @ping_to.puts "END_OF_PING"
    @ping_to.flush
  end

  # Final summary line.
  if packets_line =~ /(\d+) packets transmitted, (\d+) packets received/
      total_count   = $1.to_i
      totally_good  = $2.to_i
      if round_trip_line =~ / (\S+) ms/
        avg_ms = $1.split('/')[1].to_f  # Extract the average time.
      end
      show_stats(line_time, total_count, totally_good,
               'good', totally_good, avg_ms)
  end

  unless @dns_failing
    commit_line() # Leave this summary line as history.
  end
end
run() click to toggle source
# File bin/wifi-watch, line 14
def run
  @shown_header = false # Have we shown the column headers yet?
  @dns_failing  = false # Are DNS lookups currently failing?
  @display_line = nil   # The current display line.
  @ping_to      = nil   # Save timestamped ping output here.
  @ping_from    = nil   # Read timestamped ping input here.
  @options      = get_options()

  if @options.ping_to
    begin
      @ping_to = File.open(@options.ping_to, "w")
    rescue => e
      puts
      puts "ping_to=#{@options.ping_to}: #{e}"
      puts
      exit
    end
  end

  if @options.ping_from

    # Debug mode simulates ping output from a captured --ping-to file.
    begin
      if @options.ping_from == "-"
        @ping_from = STDIN
      else
        @ping_from = File.open(@options.ping_from, "r")
      end
    rescue => e
      puts
      puts "ping_from=#{@options.ping_from}: #{e}"
      puts
      exit
    end

    # Call ping_period() for each ping run section.
    until @ping_from.eof?
      ping_period(@ping_from) # Reads up to next "END_OF_PING" line.
    end
    if @dns_failing
      commit_line() # Show that we ended up in dns failure mode.
    end

  else

    # Run ping multiple times.
    periods = 0
    while true
      count = beautiful_times()
      cmd = "ping -c #{count} #{@options.host}"
      stdin, stdout, pid = PTY.spawn( cmd )
      ping_period(stdin)    # Run one accounting period.
      Process.waitall()     # Soak up any zombie pings.
      if @options.periods > 0
        periods += 1
        if periods >= @options.periods
          break
        end
      end
    end

  end

rescue Interrupt
  commit_line()
ensure
  if @ping_to
    @ping_to.close
  end
end
show_stats(line_time, total_count, totally_good, current_mode, mode_count, ms) click to toggle source

Show the statistics.

# File bin/wifi-watch, line 344
def show_stats(line_time, total_count, totally_good,
               current_mode, mode_count, ms)
  loss = ((total_count - totally_good) * 100.0) / total_count
  line = sprintf("%4d %4d %s %5.1f%% %7.2f ms",
                 total_count, mode_count, current_mode, loss, ms)
  hhmm_line(line_time, line)
end