#!/usr/bin/python -S
# Copyright 2015 Google Inc. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Wifi packet blaster."""

__author__ = 'mikemu@google.com (Mike Mu)'

import contextlib
import errno
import multiprocessing
import os
import re
import subprocess
import sys
import time
import traceback
import options


try:
  import monotime  # pylint: disable=unused-import,g-import-not-at-top
except ImportError:
  pass
try:
  _gettime = time.monotonic
except AttributeError:
  _gettime = time.time


_OPTSPEC = """
wifiblaster [options...] [clients...]
--
i,interface=  Name of access point interface
d,duration=   Packet blast duration in seconds [.1]
f,fraction=   Number of samples per duration [10]
s,size=       Packet size in bytes [1470]
"""


class Error(Exception):
  """Exception superclass representing a nominal test failure."""
  pass


class NotActiveError(Error):
  """Client is not active."""
  pass


class NotAssociatedError(Error):
  """Client is not associated."""
  pass


class NotSupportedError(Error):
  """Packet blasts are not supported."""
  pass


class PktgenError(Error):
  """Pktgen failure."""
  pass


class Iw(object):
  """Interface to iw."""
  # TODO(mikemu): Use an nl80211 library instead.

  def __init__(self, interface):
    """Initializes Iw on a given interface."""
    self._interface = interface

  def _DevInfo(self):
    """Returns the output of 'iw dev <interface> info'."""
    return subprocess.check_output(
        ['iw', 'dev', self._interface, 'info'])

  def _DevStationDump(self):
    """Returns the output of 'iw dev <interface> station dump'."""
    return subprocess.check_output(
        ['iw', 'dev', self._interface, 'station', 'dump'])

  def _DevStationGet(self, client):
    """Returns the output of 'iw dev <interface> station get <client>'."""
    try:
      return subprocess.check_output(
          ['iw', 'dev', self._interface, 'station', 'get', client])
    except subprocess.CalledProcessError as e:
      if e.returncode == 254:
        raise NotAssociatedError
      raise

  def GetClients(self):
    """Returns the associated clients of an interface."""
    return set([client.lower() for client in re.findall(
        r'Station ((?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2})',
        self._DevStationDump())])

  def GetFrequency(self):
    """Returns the frequency of an interface."""
    return int(re.search(r'channel.*\((\d+) MHz\)', self._DevInfo()).group(1))

  def GetPhy(self):
    """Returns the PHY name of an interface."""
    return 'phy%d' % int(re.search(r'wiphy (\d+)', self._DevInfo()).group(1))

  def GetInactiveTime(self, client):
    """Returns the inactive time of a client."""
    return float(re.search(r'inactive time:\s+(\d+) ms',
                           self._DevStationGet(client)).group(1)) / 1000

  def GetRssi(self, client):
    """Returns the RSSI of a client."""
    return float(re.search(r'signal:\s+([-.\d]+)',
                           self._DevStationGet(client)).group(1))


class Mac80211Stats(object):
  """Interface to mac80211 statistics in debugfs."""

  def __init__(self, phy):
    """Initializes Mac80211Stats on a given PHY."""
    self._basedir = os.path.join(
        '/sys/kernel/debug/ieee80211', phy, 'statistics')

  def _ReadCounter(self, counter):
    """Returns a counter read from a file."""
    try:
      with open(os.path.join(self._basedir, counter)) as f:
        return int(f.read())
    except IOError as e:
      if e.errno == errno.ENOENT:
        raise NotSupportedError
      raise

  def GetTransmittedFrameCount(self):
    """Returns the number of successfully transmitted MSDUs."""
    return self._ReadCounter('dot11TransmittedFrameCount')


class Pktgen(object):
  """Interface to pktgen."""

  def __init__(self, interface):
    """Initializes Pktgen on a given interface."""
    self._interface = interface
    self._control_file = '/proc/net/pktgen/pgctrl'
    self._thread_file = '/proc/net/pktgen/kpktgend_1'
    self._device_file = '/proc/net/pktgen/%s' % interface

  def _ReadFile(self, filename):
    """Returns the contents of a file."""
    try:
      with open(filename) as f:
        return f.read()
    except IOError as e:
      if e.errno == errno.ENOENT:
        raise NotSupportedError
      raise

  def _WriteFile(self, filename, s):
    """Writes a string and a newline to a file."""
    try:
      with open(filename, 'w') as f:
        f.write(s + '\n')
    except IOError as e:
      if e.errno == errno.ENOENT:
        raise NotSupportedError
      raise

  @contextlib.contextmanager
  def PacketBlast(self, client, size):
    """Runs a packet blast."""
    # Reset pktgen.
    self._WriteFile(self._control_file, 'reset')
    self._WriteFile(self._thread_file, 'add_device %s' % self._interface)

    # Work around a bug on GFRG200 where transmits hang on queues other than BE.
    self._WriteFile(self._device_file, 'queue_map_min 2')
    self._WriteFile(self._device_file, 'queue_map_max 2')

    # Disable packet count limit.
    self._WriteFile(self._device_file, 'count 0')

    # Set parameters.
    self._WriteFile(self._device_file, 'dst_mac %s' % client)
    self._WriteFile(self._device_file, 'pkt_size %d' % size)

    # Start packet blast.
    p = multiprocessing.Process(target=self._WriteFile,
                                args=[self._control_file, 'start'])
    p.start()

    # Wait for pktgen startup delay. pktgen prints 'Starting' after the packet
    # blast has started.
    while (p.is_alive() and not
           re.search(r'Result: Starting', self._ReadFile(self._device_file))):
      pass

    # Run with-statement body.
    try:
      yield

    # Stop packet blast.
    finally:
      p.terminate()
      p.join()


def _PacketBlast(iw, mac80211stats, pktgen, client, duration, fraction, size):
  """Blasts packets at a client and returns a string representing the result."""
  try:
    # Validate client.
    if client not in iw.GetClients():
      raise NotAssociatedError

    with pktgen.PacketBlast(client, size):
      # Wait for the client's inactive time to become zero, which happens when
      # an ack is received from the client. Assume the client has disassociated
      # after 2s.
      start = _gettime()
      while _gettime() < start + 2:
        if iw.GetInactiveTime(client) == 0:
          break
      else:
        raise NotActiveError

      # Wait for block ack session and max PHY rate negotiation.
      time.sleep(.1)

      # Sample transmitted frame count.
      samples = [mac80211stats.GetTransmittedFrameCount()]
      start = _gettime()
      dt = duration / fraction
      for t in [start + dt * (i + 1) for i in xrange(fraction)]:
        time.sleep(t - _gettime())
        samples.append(mac80211stats.GetTransmittedFrameCount())

    # Compute throughputs from samples.
    samples = [8 * size * (after - before) / dt
               for (after, before) in zip(samples[1:], samples[:-1])]

    # Print result.
    print ('version=2 mac=%s throughput=%d rssi=%g frequency=%d samples=%s') % (
        client,
        sum(samples) / len(samples),
        iw.GetRssi(client),
        iw.GetFrequency(),
        ','.join(['%d' % sample for sample in samples]))

  except Error as e:
    print 'version=2 mac=%s error=%s' % (client, e.__class__.__name__)
    traceback.print_exc()
  except Exception as e:
    print 'version=2 mac=%s error=%s' % (client, e.__class__.__name__)
    raise


def main():
  # Parse and validate arguments.
  o = options.Options(_OPTSPEC)
  (opt, _, clients) = o.parse(sys.argv[1:])
  opt.duration = float(opt.duration)
  opt.fraction = int(opt.fraction)
  opt.size = int(opt.size)
  if not opt.interface:
    o.fatal('must specify --interface')
  if opt.duration <= 0:
    o.fatal('--duration must be positive')
  if opt.fraction <= 0:
    o.fatal('--fraction must be a positive integer')
  if opt.size <= 0:
    o.fatal('--size must be a positive integer')

  # Initialize iw, mac80211stats, and pktgen.
  iw = Iw(opt.interface)
  mac80211stats = Mac80211Stats(iw.GetPhy())
  pktgen = Pktgen(opt.interface)

  # If no clients are specified, test all associated clients.
  if not clients:
    clients = sorted(iw.GetClients())

  # Normalize clients.
  clients = [client.lower() for client in clients]

  # Blast packets at each client.
  for client in clients:
    _PacketBlast(iw, mac80211stats, pktgen, client,
                 opt.duration, opt.fraction, opt.size)


if __name__ == '__main__':
  main()
