platform: add a new jsonpoll command.

Chimera will run a web server that exports stats for the radio and modem
(like temperatures, tx/rx counters, etc) via a RESTful JSON web interface.

jsonpoll periodically connects to that web server (known for now as
Glaukus Manager) and requests stats. jsonpoll then writes the response
out to disk (under /tmp/glaukus/) for catawampus to then read and export
via cwmp.

Unit tested and lint-free. Will need to include this in buildroot as a
next step. Further work will entail creating and updating additional
JSON requests.

Change-Id: Id4cc2f0ac55a5678569fd4604a3ece8f5bc005d8
diff --git a/jsonpoll/Makefile b/jsonpoll/Makefile
new file mode 100644
index 0000000..a3c24df
--- /dev/null
+++ b/jsonpoll/Makefile
@@ -0,0 +1,23 @@
+default:
+
+PREFIX=/
+BINDIR=$(DESTDIR)$(PREFIX)/bin
+PYTHON?=python
+
+all:
+
+install:
+	mkdir -p $(BINDIR)
+	cp jsonpoll.py $(BINDIR)/jsonpoll
+
+install-libs:
+	@echo "No libs to install."
+
+test:
+	$(PYTHON) jsonpoll_test.py
+
+clean:
+	rm -rf *.pyc
+
+lint:
+	gpylint jsonpoll.py jsonpoll_test.py
diff --git a/jsonpoll/jsonpoll.py b/jsonpoll/jsonpoll.py
new file mode 100755
index 0000000..dbdbc85
--- /dev/null
+++ b/jsonpoll/jsonpoll.py
@@ -0,0 +1,142 @@
+#!/usr/bin/python
+# 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.
+
+"""JsonPoll reads a response from a socket and writes it to disk."""
+
+__author__ = 'cgibson@google.com (Chris Gibson)'
+
+import errno
+import os
+import socket
+import sys
+import tempfile
+import time
+import urllib
+import urllib2
+import options
+
+
+optspec = """
+jsonpoll [options]
+--
+h,host=      host to connect to [localhost]
+p,port=      port to connect to [8000]
+"""
+
+
+class JsonPoll(object):
+  """Periodically poll a web server to request stats."""
+
+  # The directory that JSON files will be written to. Note that changing this
+  # path to another filesystem other than /tmp will affect the atomicity of the
+  # os.rename() when moving files into the final destination.
+  OUTPUT_DIR = '/tmp/glaukus/'
+
+  # The time to wait between requests.
+  _DURATION_BETWEEN_POLLS_SECS = 15
+
+  # The time to wait before giving up on blocking connection operations.
+  _SOCKET_TIMEOUT_SECS = 15
+
+  def __init__(self, host, port):
+    self.hostport = 'http://%s:%d' % (host, port)
+    # TODO(cgibson): Support more request types once Glaukus Manager's JSON spec
+    # is more stable.
+    self.report_output_file = os.path.join(self.OUTPUT_DIR, 'report.json')
+    self.paths_to_statfiles = {'info/report': self.report_output_file}
+    self.last_response = None
+
+  def RequestStats(self):
+    """Sends a request via HTTP POST to the specified web server."""
+    for path, output_file in self.paths_to_statfiles.iteritems():
+      url = '%s/%s' % (self.hostport, path)
+      # TODO(cgibson): POST data might need to get folded into the dict somehow
+      # once we know a bit more about the actual Glaukus implementation works
+      # and what real requests will look like.
+      post_data = {'info': None}
+      tmpfile = ''
+      try:
+        response = self.GetHttpResponse(url, post_data, output_file)
+        if not response:
+          return False
+        elif self.last_response == response:
+          print 'Skipping file write as content has not changed.'
+          return True
+        self.last_response = response
+        with tempfile.NamedTemporaryFile(delete=False) as fd:
+          if not self.CreateDirs(os.path.dirname(output_file)):
+            print ('Failed to create output directory: %s' %
+                   os.path.dirname(output_file))
+            return False
+          tmpfile = fd.name
+          fd.write(response)
+          fd.flush()
+          os.fsync(fd.fileno())
+          print 'Wrote %d bytes to %s' % (len(response), tmpfile)
+          try:
+            os.rename(tmpfile, output_file)
+          except OSError as ex:
+            print 'Failed to move %s to %s: %s' % (tmpfile, output_file, ex)
+            return False
+          print 'Moved %s to %s' % (tmpfile, output_file)
+        return True
+      finally:
+        if os.path.exists(tmpfile):
+          os.unlink(tmpfile)
+
+  def GetHttpResponse(self, url, post_data, output_file):
+    """Creates a request and retrieves the response from a web server."""
+    print 'Connecting to %s, post_data:%s, output_file:%s' % (url, post_data,
+                                                              output_file)
+    data = urllib.urlencode(post_data)
+    req = urllib2.Request(url, data)
+    try:
+      handle = urllib2.urlopen(req, timeout=self._SOCKET_TIMEOUT_SECS)
+      response = handle.read()
+    except socket.timeout as ex:
+      print ('Connection to %s timed out after %d seconds: %s'
+             % (url, self._SOCKET_TIMEOUT_SECS, ex))
+      return None
+    except urllib2.URLError as ex:
+      print 'Connection to %s failed: %s' % (url, ex.reason)
+      return None
+    return response
+
+  def CreateDirs(self, dir_to_create):
+    """Recursively creates directories."""
+    try:
+      os.makedirs(dir_to_create)
+    except os.error as ex:
+      if ex.errno == errno.EEXIST:
+        return True
+      print 'Failed to create directory: %s' % ex
+      return False
+    return True
+
+  def RunForever(self):
+    while True:
+      self.RequestStats()
+      time.sleep(self._DURATION_BETWEEN_POLLS_SECS)
+
+
+def main():
+  o = options.Options(optspec)
+  (opt, unused_flags, unused_extra) = o.parse(sys.argv[1:])
+  poller = JsonPoll(opt.host, opt.port)
+  poller.RunForever()
+
+
+if __name__ == '__main__':
+  main()
diff --git a/jsonpoll/jsonpoll_test.py b/jsonpoll/jsonpoll_test.py
new file mode 100644
index 0000000..1fd9571
--- /dev/null
+++ b/jsonpoll/jsonpoll_test.py
@@ -0,0 +1,135 @@
+#!/usr/bin/python
+# 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.
+
+"""Tests for jsonpoll."""
+
+__author__ = 'cgibson@google.com (Chris Gibson)'
+
+import json
+import os
+import tempfile
+import unittest
+import jsonpoll
+
+JSON_RESPONSE = {
+    'report': {
+        'abs_mse_db': '-3276.8',
+        'adc_count': '3359',
+        'external_agc_ind': '9.2',
+        'in_power_rssi_dbc': '-12.0',
+        'inband_power_rssi_dbc': '-67.2',
+        'internal_agc_db': '55.2',
+        'msr_pwr_rssi_dbm': '-20.0',
+        'norm_mse_db': '-3270.9',
+        'num_samples': 'Undefined',
+        'rad_mse_db': '-3267.9',
+        'rx_lock_loss_events': 'Undefined',
+        'rx_lock_loss_time_ms': 'Undefined',
+        'rx_locked': '0',
+        'sample_end_tstamp_ms': '1444839743297',
+        'sample_start_tstamp_ms': '1444839743287'
+    },
+    'result': {
+        'err_code': 0,
+        'err_msg': 'None',
+        'status': 'SUCCESS'
+    },
+    'running_config': {
+        'acmb_enable': True,
+        'bgt_tx_vga_gain_ind': 0,
+        'heartbeat_rate': 60,
+        'ip_addr': '10.0.0.40',
+        'ip_gateway': '10.0.0.1',
+        'ip_netmask': '255.255.255.0',
+        'ipv6_addr': 'fe80::7230:d5ff:fe00:1418',
+        'manual_acmb_profile_indx': 0,
+        'modem_cfg_file': 'default.bin',
+        'modem_on': True,
+        'pa_lna_enable': True,
+        'radio_heater_on': False,
+        'radio_on': True,
+        'report_avg_window_ms': 10,
+        'report_dest_ip': '192.168.1.1',
+        'report_dest_port': 4950,
+        'report_enable': True,
+        'report_interval_hz': 1,
+        'rx_khz': 85500000,
+        'tx_khz': 75500000
+    },
+}
+
+
+class FakeJsonPoll(jsonpoll.JsonPoll):
+  """Mock JsonPoll."""
+
+  def GetHttpResponse(self, unused_url, unused_postdata, unused_output_file):
+    self.get_response_called = True
+    return json.dumps(JSON_RESPONSE)
+
+
+class JsonPollTest(unittest.TestCase):
+
+  def setUp(self):
+    self.CreateTempFile()
+    self.poller = FakeJsonPoll('fakehost.blah', 31337)
+
+  def tearDown(self):
+    self.DeleteTempFile()
+
+  def CreateTempFile(self):
+    # Create a temp file and have that be the target output file.
+    fd, self.output_file = tempfile.mkstemp()
+    os.close(fd)
+
+  def DeleteTempFile(self):
+    if os.path.exists(self.output_file):
+      os.unlink(self.output_file)
+
+  def testRequestStats(self):
+    # Create a fake entry in the paths_to_stats map.
+    self.poller.paths_to_statfiles = {'fake/url': self.output_file}
+    self.poller.RequestStats()
+    self.assertEqual(True, self.poller.get_response_called)
+
+    # Read back the contents of the fake output file. It should be the
+    # equivalent JSON representation we wrote out from the mock.
+    with open(self.output_file, 'r') as f:
+      output = ''.join(line.rstrip() for line in f)
+      self.assertEqual(json.dumps(JSON_RESPONSE), output)
+
+  def testRequestStatsFailureToCreateOutputFile(self):
+    self.poller.paths_to_statfiles = {'fake/url': '/root/cannotwrite'}
+    result = self.poller.RequestStats()
+    self.assertEqual(False, result)
+
+  def testCachedRequestStats(self):
+    # Set the "last_response" as our mock output. This should mean we do not
+    # write anything to the output file.
+    self.poller.last_response = json.dumps(JSON_RESPONSE)
+
+    # Create a fake entry in the paths_to_stats map.
+    self.poller.paths_to_statfiles = {'fake/url': self.output_file}
+    result = self.poller.RequestStats()
+    self.assertEqual(True, self.poller.get_response_called)
+    self.assertEqual(True, result)
+
+    # Read back the contents of the fake output file: It should be empty.
+    with open(self.output_file, 'r') as f:
+      output = ''.join(line.rstrip() for line in f)
+      self.assertEqual('', output)
+
+
+if __name__ == '__main__':
+  unittest.main()
diff --git a/jsonpoll/options.py b/jsonpoll/options.py
new file mode 100644
index 0000000..7fb5bcf
--- /dev/null
+++ b/jsonpoll/options.py
@@ -0,0 +1,274 @@
+# Copyright 2010-2012 Avery Pennarun and options.py contributors.
+# All rights reserved.
+#
+# (This license applies to this file but not necessarily the other files in
+# this package.)
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+#    1. Redistributions of source code must retain the above copyright
+#       notice, this list of conditions and the following disclaimer.
+#
+#    2. Redistributions in binary form must reproduce the above copyright
+#       notice, this list of conditions and the following disclaimer in
+#       the documentation and/or other materials provided with the
+#       distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY AVERY PENNARUN ``AS IS'' AND ANY
+# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> OR
+# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+"""Command-line options parser.
+With the help of an options spec string, easily parse command-line options.
+
+An options spec is made up of two parts, separated by a line with two dashes.
+The first part is the synopsis of the command and the second one specifies
+options, one per line.
+
+Each non-empty line in the synopsis gives a set of options that can be used
+together.
+
+Option flags must be at the begining of the line and multiple flags are
+separated by commas. Usually, options have a short, one character flag, and a
+longer one, but the short one can be omitted.
+
+Long option flags are used as the option's key for the OptDict produced when
+parsing options.
+
+When the flag definition is ended with an equal sign, the option takes one
+string as an argument. Otherwise, the option does not take an argument and
+corresponds to a boolean flag that is true when the option is given on the
+command line.
+
+The option's description is found at the right of its flags definition, after
+one or more spaces. The description ends at the end of the line. If the
+description contains text enclosed in square brackets, the enclosed text will
+be used as the option's default value.
+
+Options can be put in different groups. Options in the same group must be on
+consecutive lines. Groups are formed by inserting a line that begins with a
+space. The text on that line will be output after an empty line.
+"""
+import sys, os, textwrap, getopt, re, struct
+
+
+def _invert(v, invert):
+    if invert:
+        return not v
+    return v
+
+
+def _remove_negative_kv(k, v):
+    if k.startswith('no-') or k.startswith('no_'):
+        return k[3:], not v
+    return k,v
+
+
+class OptDict(object):
+    """Dictionary that exposes keys as attributes.
+
+    Keys can be set or accessed with a "no-" or "no_" prefix to negate the
+    value.
+    """
+    def __init__(self, aliases):
+        self._opts = {}
+        self._aliases = aliases
+
+    def _unalias(self, k):
+        k, reinvert = _remove_negative_kv(k, False)
+        k, invert = self._aliases[k]
+        return k, invert ^ reinvert
+
+    def __setitem__(self, k, v):
+        k, invert = self._unalias(k)
+        self._opts[k] = _invert(v, invert)
+
+    def __getitem__(self, k):
+        k, invert = self._unalias(k)
+        return _invert(self._opts[k], invert)
+
+    def __getattr__(self, k):
+        return self[k]
+
+
+def _default_onabort(msg):
+    sys.exit(97)
+
+
+def _intify(v):
+    try:
+        vv = int(v or '')
+        if str(vv) == v:
+            return vv
+    except ValueError:
+        pass
+    return v
+
+
+def _atoi(v):
+    try:
+        return int(v or 0)
+    except ValueError:
+        return 0
+
+
+def _tty_width():
+    s = struct.pack("HHHH", 0, 0, 0, 0)
+    try:
+        import fcntl, termios
+        s = fcntl.ioctl(sys.stderr.fileno(), termios.TIOCGWINSZ, s)
+    except (IOError, ImportError):
+        return _atoi(os.environ.get('WIDTH')) or 70
+    (ysize,xsize,ypix,xpix) = struct.unpack('HHHH', s)
+    return xsize or 70
+
+
+class Options:
+    """Option parser.
+    When constructed, a string called an option spec must be given. It
+    specifies the synopsis and option flags and their description.  For more
+    information about option specs, see the docstring at the top of this file.
+
+    Two optional arguments specify an alternative parsing function and an
+    alternative behaviour on abort (after having output the usage string).
+
+    By default, the parser function is getopt.gnu_getopt, and the abort
+    behaviour is to exit the program.
+    """
+    def __init__(self, optspec, optfunc=getopt.gnu_getopt,
+                 onabort=_default_onabort):
+        self.optspec = optspec
+        self._onabort = onabort
+        self.optfunc = optfunc
+        self._aliases = {}
+        self._shortopts = 'h?'
+        self._longopts = ['help', 'usage']
+        self._hasparms = {}
+        self._defaults = {}
+        self._usagestr = self._gen_usage()  # this also parses the optspec
+
+    def _gen_usage(self):
+        out = []
+        lines = self.optspec.strip().split('\n')
+        lines.reverse()
+        first_syn = True
+        while lines:
+            l = lines.pop()
+            if l == '--': break
+            out.append('%s: %s\n' % (first_syn and 'usage' or '   or', l))
+            first_syn = False
+        out.append('\n')
+        last_was_option = False
+        while lines:
+            l = lines.pop()
+            if l.startswith(' '):
+                out.append('%s%s\n' % (last_was_option and '\n' or '',
+                                       l.lstrip()))
+                last_was_option = False
+            elif l:
+                (flags,extra) = (l + ' ').split(' ', 1)
+                extra = extra.strip()
+                if flags.endswith('='):
+                    flags = flags[:-1]
+                    has_parm = 1
+                else:
+                    has_parm = 0
+                g = re.search(r'\[([^\]]*)\]$', extra)
+                if g:
+                    defval = _intify(g.group(1))
+                else:
+                    defval = None
+                flagl = flags.split(',')
+                flagl_nice = []
+                flag_main, invert_main = _remove_negative_kv(flagl[0], False)
+                self._defaults[flag_main] = _invert(defval, invert_main)
+                for _f in flagl:
+                    f,invert = _remove_negative_kv(_f, 0)
+                    self._aliases[f] = (flag_main, invert_main ^ invert)
+                    self._hasparms[f] = has_parm
+                    if f == '#':
+                        self._shortopts += '0123456789'
+                        flagl_nice.append('-#')
+                    elif len(f) == 1:
+                        self._shortopts += f + (has_parm and ':' or '')
+                        flagl_nice.append('-' + f)
+                    else:
+                        f_nice = re.sub(r'\W', '_', f)
+                        self._aliases[f_nice] = (flag_main,
+                                                 invert_main ^ invert)
+                        self._longopts.append(f + (has_parm and '=' or ''))
+                        self._longopts.append('no-' + f)
+                        flagl_nice.append('--' + _f)
+                flags_nice = ', '.join(flagl_nice)
+                if has_parm:
+                    flags_nice += ' ...'
+                prefix = '    %-20s  ' % flags_nice
+                argtext = '\n'.join(textwrap.wrap(extra, width=_tty_width(),
+                                                initial_indent=prefix,
+                                                subsequent_indent=' '*28))
+                out.append(argtext + '\n')
+                last_was_option = True
+            else:
+                out.append('\n')
+                last_was_option = False
+        return ''.join(out).rstrip() + '\n'
+
+    def usage(self, msg=""):
+        """Print usage string to stderr and abort."""
+        sys.stderr.write(self._usagestr)
+        if msg:
+            sys.stderr.write(msg)
+        e = self._onabort and self._onabort(msg) or None
+        if e:
+            raise e
+
+    def fatal(self, msg):
+        """Print an error message to stderr and abort with usage string."""
+        msg = '\nerror: %s\n' % msg
+        return self.usage(msg)
+
+    def parse(self, args):
+        """Parse a list of arguments and return (options, flags, extra).
+
+        In the returned tuple, "options" is an OptDict with known options,
+        "flags" is a list of option flags that were used on the command-line,
+        and "extra" is a list of positional arguments.
+        """
+        try:
+            (flags,extra) = self.optfunc(args, self._shortopts, self._longopts)
+        except getopt.GetoptError, e:
+            self.fatal(e)
+
+        opt = OptDict(aliases=self._aliases)
+
+        for k,v in self._defaults.iteritems():
+            opt[k] = v
+
+        for (k,v) in flags:
+            k = k.lstrip('-')
+            if k in ('h', '?', 'help', 'usage'):
+                self.usage()
+            if (self._aliases.get('#') and
+                  k in ('0','1','2','3','4','5','6','7','8','9')):
+                v = int(k)  # guaranteed to be exactly one digit
+                k, invert = self._aliases['#']
+                opt['#'] = v
+            else:
+                k, invert = opt._unalias(k)
+                if not self._hasparms[k]:
+                    assert(v == '')
+                    v = (opt._opts.get(k) or 0) + 1
+                else:
+                    v = _intify(v)
+            opt[k] = _invert(v, invert)
+        return (opt,flags,extra)