webplot: Refactor the webplot module as a plotter

Previously, the webplot module was designed to run standalone to
draw finger traces. It is required to refactor the module so that
it could be imported nicely as a plotter.

In particular, we would like the module to be capable of the
following two use cases.

Use case 1: embedding Webplot as a plotter in an application

  # Instantiate a webplot server and starts the daemon.
  plot = Webplot(server_addr, server_port, device)
  plot.start()

  # Repeatedly get a snapshot and add it for plotting.
  while True:
    # GetSnapshot() is essentially device.NextSnapshot()
    snapshot = plot.GetSnapshot()
    if not snapshot:
      break
    # Add the snapshot to the plotter for plotting.
    plot.AddSnapshot(snapshot)

  # Save a screen dump
  plot.Save()

  # Notify the browser to clear the screen.
  plot.Clear()

  # Notify both the browser and the cherrypy engine to quit.
  plot.Quit()

Use case 2: using webplot standalone

  # Instantiate a webplot server and starts the daemon.
  plot = Webplot(server_addr, server_port, device)
  plot.start()

  # Get touch snapshots from the touch device and have clients plot them.
  webplot.GetAndPlotSnapshots()

BUG=chromium:474952
TEST=emerge and deploy webplot to a chromebook.
Launch the webplot with the following command on the chromebook.
$ webplot

Type "localhost" on the browser. Draw fingers on the touchpad.
Press 'q' to terminate the webplot.
All operations should execute correctly.

CQ-DEPEND=I111f88f4b42916274fc1f5d0bda3183dfee2415e

Change-Id: Ie03d8084e42ed0dd8f05ba7bebef3ba1e978fe7c
Reviewed-on: https://chromium-review.googlesource.com/264633
Reviewed-by: Charlie Mooney <charliemooney@chromium.org>
Commit-Queue: Shyh-In Hwang <josephsih@chromium.org>
Tested-by: Shyh-In Hwang <josephsih@chromium.org>
diff --git a/webplot/webplot.py b/webplot/webplot.py
index 5232125..af62e9b 100755
--- a/webplot/webplot.py
+++ b/webplot/webplot.py
@@ -33,6 +33,52 @@
 SAVED_IMAGE = '/tmp/webplot.png'
 
 
+def SimpleSystem(cmd):
+  """Execute a system command."""
+  ret = subprocess.call(cmd, shell=True)
+  if ret:
+    logging.warning('Command (%s) failed (ret=%s).', cmd, ret)
+  return ret
+
+
+def SimpleSystemOutput(cmd):
+  """Execute a system command and get its output."""
+  try:
+    proc = subprocess.Popen(
+        cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
+    stdout, _ = proc.communicate()
+  except Exception, e:
+    logging.warning('Command (%s) failed (%s).', cmd, e)
+  else:
+    return None if proc.returncode else stdout.strip()
+
+
+def IsDestinationPortEnabled(port):
+  """Check if the destination port is enabled in iptables.
+
+  If port 8000 is enabled, it looks like
+    ACCEPT  tcp  --  0.0.0.0/0  0.0.0.0/0  ctstate NEW tcp dpt:8000
+  """
+  pattern = re.compile('ACCEPT\s+tcp.+\s+ctstate\s+NEW\s+tcp\s+dpt:%d' % port)
+  rules = SimpleSystemOutput('sudo iptables -L INPUT -n --line-number')
+  for rule in rules.splitlines():
+    if pattern.search(rule):
+      return True
+  return False
+
+
+def EnableDestinationPort(port):
+  """Enable the destination port for input traffic in iptables."""
+  if IsDestinationPortEnabled(port):
+    cherrypy.log('Port %d has been already enabled in iptables.' % port)
+  else:
+    cherrypy.log('To enable port %d in iptables.' % port)
+    cmd = ('sudo iptables -A INPUT -p tcp -m conntrack --ctstate NEW '
+           '--dport %d -j ACCEPT' % port)
+    if SimpleSystem(cmd) != 0:
+      raise Error('Failed to enable port in iptables: %d.' % port)
+
+
 def InterruptHandler():
   """An interrupt handler for both SIGINT and SIGTERM
 
@@ -107,69 +153,6 @@
     return '\n  '.join(sorted([str(slot) for slot in self.slots.values()]))
 
 
-def ThreadedGetLiveStreamSnapshots(device, saved_file):
-  """A thread to poll and get live stream snapshots continuously."""
-
-  def _ConvertNamedtupleToDict(snapshot, prev_tids):
-    """Convert namedtuples to ordinary dictionaries and add leaving slots.
-
-    This is to make a snapshot json serializable. Otherwise, the namedtuples
-    would be transmitted as arrays which is less readable.
-
-    A snapshot looks like
-      MtSnapshot(
-          syn_time=1420524008.368854,
-          button_pressed=False,
-          fingers=[
-              MtFinger(tid=162, slot=0, syn_time=1420524008.368854, x=524,
-                        y=231, pressure=45),
-              MtFinger(tid=163, slot=1, syn_time=1420524008.368854, x=677,
-                        y=135, pressure=57)
-          ]
-      )
-
-    Note:
-    1. that there are two levels of namedtuples to convert.
-    2. The leaving slots are used to notify javascript that a finger is leaving
-       so that the corresponding finger color could be released for reuse.
-    """
-    # Convert MtSnapshot.
-    converted = dict(snapshot.__dict__.items())
-
-    # Convert MtFinger.
-    converted['fingers'] = [dict(finger.__dict__.items())
-                            for finger in converted['fingers']]
-    converted['raw_events'] = [str(event) for event in converted['raw_events']]
-
-    # Add leaving fingers to notify js for reclaiming the finger colors.
-    curr_tids = [finger['tid'] for finger in converted['fingers']]
-    for tid in set(prev_tids) - set(curr_tids):
-      leaving_finger = {'tid': tid, 'leaving': True}
-      converted['fingers'].append(leaving_finger)
-
-    return converted, curr_tids
-
-  def _GetSnapshots():
-    """Get live stream snapshots."""
-    cherrypy.log('Start getting the live stream snapshots....')
-    prev_tids = []
-    with open(saved_file, 'w') as f:
-      while True:
-        snapshot = device.device.NextSnapshot()
-        # TODO: remove the next line when NextSnapshot returns the raw events.
-        events = []
-        if snapshot:
-          f.write('\n'.join(events) + '\n')
-          f.flush()
-          snapshot, prev_tids = _ConvertNamedtupleToDict(snapshot, prev_tids)
-          cherrypy.engine.publish('websocket-broadcast', json.dumps(snapshot))
-
-  get_snapshot_thread = threading.Thread(target=_GetSnapshots,
-                                         name='_GetSnapshots')
-  get_snapshot_thread.daemon = True
-  get_snapshot_thread.start()
-
-
 class ConnectionState(object):
   """A ws connection state object for shutting down the cherrypy server.
 
@@ -258,50 +241,188 @@
     state.IncCount()
 
 
-def SimpleSystem(cmd):
-  """Execute a system command."""
-  ret = subprocess.call(cmd, shell=True)
-  if ret:
-    logging.warning('Command (%s) failed (ret=%s).', cmd, ret)
-  return ret
+class Webplot(threading.Thread):
+  """The server handling the Plotting of finger traces.
+
+  Use case 1: embedding Webplot as a plotter in an application
+
+    # Instantiate a webplot server and starts the daemon.
+    plot = Webplot(server_addr, server_port, device)
+    plot.start()
+
+    # Repeatedly get a snapshot and add it for plotting.
+    while True:
+      # GetSnapshot() is essentially device.NextSnapshot()
+      snapshot = plot.GetSnapshot()
+      if not snapshot:
+        break
+      # Add the snapshot to the plotter for plotting.
+      plot.AddSnapshot(snapshot)
+
+    # Save a screen dump
+    plot.Save()
+
+    # Notify the browser to clear the screen.
+    plot.Clear()
+
+    # Notify both the browser and the cherrypy engine to quit.
+    plot.Quit()
 
 
-def SimpleSystemOutput(cmd):
-  """Execute a system command and get its output."""
-  try:
-    proc = subprocess.Popen(
-        cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
-    stdout, _ = proc.communicate()
-  except Exception, e:
-    logging.warning('Command (%s) failed (%s).', cmd, e)
-  else:
-    return None if proc.returncode else stdout.strip()
+  Use case 2: using webplot standalone
 
+    # Instantiate a webplot server and starts the daemon.
+    plot = Webplot(server_addr, server_port, device)
+    plot.start()
 
-def IsDestinationPortEnabled(port):
-  """Check if the destination port is enabled in iptables.
-
-  If port 8000 is enabled, it looks like
-    ACCEPT  tcp  --  0.0.0.0/0  0.0.0.0/0  ctstate NEW tcp dpt:8000
+    # Get touch snapshots from the touch device and have clients plot them.
+    webplot.GetAndPlotSnapshots()
   """
-  pattern = re.compile('ACCEPT\s+tcp.+\s+ctstate\s+NEW\s+tcp\s+dpt:%d' % port)
-  rules = SimpleSystemOutput('sudo iptables -L INPUT -n --line-number')
-  for rule in rules.splitlines():
-    if pattern.search(rule):
-      return True
-  return False
 
+  def __init__(self, server_addr, server_port, device, saved_file=SAVED_FILE):
+    self._server_addr = server_addr
+    self._server_port = server_port
+    self._device = device
+    self._saved_file = saved_file
+    super(Webplot, self).__init__(name='webplot thread')
 
-def EnableDestinationPort(port):
-  """Enable the destination port for input traffic in iptables."""
-  if IsDestinationPortEnabled(port):
-    cherrypy.log('Port %d has been already enabled in iptables.' % port)
-  else:
-    cherrypy.log('To enable port %d in iptables.' % port)
-    cmd = ('sudo iptables -A INPUT -p tcp -m conntrack --ctstate NEW '
-           '--dport %d -j ACCEPT' % port)
-    if SimpleSystem(cmd) != 0:
-      raise Error('Failed to enable port in iptables: %d.' % port)
+    self.daemon = True
+    self._prev_tids = []
+
+    # Allow input traffic in iptables.
+    EnableDestinationPort(self._server_port)
+
+    # Create a ws connection state object to wait for the condition to
+    # shutdown the whole process.
+    global state
+    state = ConnectionState()
+
+    cherrypy.config.update({
+      'server.socket_host': self._server_addr,
+      'server.socket_port': self._server_port,
+    })
+
+    WebSocketPlugin(cherrypy.engine).subscribe()
+    cherrypy.tools.websocket = WebSocketTool()
+
+    # If the cherrypy server exits for whatever reason, close the device
+    # for required cleanup. Otherwise, there might exist local/remote
+    # zombie processes.
+    cherrypy.engine.subscribe('exit',  self._device.close)
+
+    cherrypy.engine.signal_handler.handlers['SIGINT'] = InterruptHandler
+    cherrypy.engine.signal_handler.handlers['SIGTERM'] = InterruptHandler
+
+  def run(self):
+    """Start the cherrypy engine."""
+    x_min, x_max = self._device.device.RangeX()
+    y_min, y_max = self._device.device.RangeY()
+    p_min, p_max = self._device.device.RangeP()
+
+    cherrypy.quickstart(
+        Root(self._server_addr, self._server_port,
+             x_min, x_max, y_min, y_max, p_min, p_max),
+        '',
+        config={
+            '/': {
+                'tools.staticdir.root':
+                    os.path.abspath(os.path.dirname(__file__)),
+                'tools.staticdir.on': True,
+                'tools.staticdir.dir': '',
+            },
+            '/ws': {
+                'tools.websocket.on': True,
+                'tools.websocket.handler_cls': WebplotWSHandler,
+            },
+        }
+    )
+
+  def _ConvertNamedtupleToDict(self, snapshot):
+    """Convert namedtuples to ordinary dictionaries and add leaving slots.
+
+    This is to make a snapshot json serializable. Otherwise, the namedtuples
+    would be transmitted as arrays which is less readable.
+
+    A snapshot looks like
+      MtSnapshot(
+          syn_time=1420524008.368854,
+          button_pressed=False,
+          fingers=[
+              MtFinger(tid=162, slot=0, syn_time=1420524008.368854, x=524,
+                        y=231, pressure=45),
+              MtFinger(tid=163, slot=1, syn_time=1420524008.368854, x=677,
+                        y=135, pressure=57)
+          ]
+      )
+
+    Note:
+    1. that there are two levels of namedtuples to convert.
+    2. The leaving slots are used to notify javascript that a finger is leaving
+       so that the corresponding finger color could be released for reuse.
+    """
+    # Convert MtSnapshot.
+    converted = dict(snapshot.__dict__.items())
+
+    # Convert MtFinger.
+    converted['fingers'] = [dict(finger.__dict__.items())
+                            for finger in converted['fingers']]
+    converted['raw_events'] = [str(event) for event in converted['raw_events']]
+
+    # Add leaving fingers to notify js for reclaiming the finger colors.
+    curr_tids = [finger['tid'] for finger in converted['fingers']]
+    for tid in set(self._prev_tids) - set(curr_tids):
+      leaving_finger = {'tid': tid, 'leaving': True}
+      converted['fingers'].append(leaving_finger)
+    self._prev_tids = curr_tids
+
+    return converted
+
+  def GetSnapshot(self):
+    """Get a snapshot from the touch device."""
+    return self._device.device.NextSnapshot()
+
+  def AddSnapshot(self, snapshot):
+    """Convert the snapshot to a proper format and publish it to clients."""
+    snapshot = self._ConvertNamedtupleToDict(snapshot)
+    cherrypy.engine.publish('websocket-broadcast', json.dumps(snapshot))
+
+  def GetAndPlotSnapshots(self):
+    """Get and plot snapshots."""
+    cherrypy.log('Start getting the live stream snapshots....')
+    with open(self._saved_file, 'w') as f:
+      while True:
+        snapshot = self.GetSnapshot()
+        if not snapshot:
+          cherrypy.log('webplot is terminated.')
+          break
+        # TODO: get the raw events from the sanpshot
+        events = []
+        f.write('\n'.join(events) + '\n')
+        f.flush()
+        self.AddSnapshot(snapshot)
+
+  def Publish(self, msg):
+    """Publish a message to clients."""
+    cherrypy.engine.publish('websocket-broadcast', TextMessage(msg))
+
+  def Clear(self):
+    """Notify clients to clear the display."""
+    self.Publish('clear')
+
+  def Quit(self):
+    """Notify clients to quit.
+
+    Note that the cherrypy engine would quit accordingly.
+    """
+    self.Publish('quit')
+
+  def Save(self):
+    """Notify clients to save the screen."""
+    self.Publish('save')
+
+  def Url(self):
+    """The url the server is serving at."""
+    return 'http://%s:%d' % (self._server_addr, self._server_port)
 
 
 def _ParseArguments():
@@ -323,8 +444,6 @@
 
 def Main():
   """The main function to launch webplot service."""
-  global state
-
   configure_logger(level=logging.DEBUG)
   args = _ParseArguments()
 
@@ -354,54 +473,15 @@
   print 'Press \'q\' on the browser to quit.'
   print '*' * 70 + '\n\n'
 
-  # Allow input traffic in iptables.
-  EnableDestinationPort(args.server_port)
-
   # Instantiate a touch device.
   device = TouchDeviceWrapper(args.dut_type, args.dut_addr, args.is_touchscreen)
 
-  # Start to get touch snapshots from the specified touch device.
-  ThreadedGetLiveStreamSnapshots(device, SAVED_FILE)
+  # Instantiate a webplot server daemon and start it.
+  webplot = Webplot(args.server_addr, args.server_port, device)
+  webplot.start()
 
-  # Create a ws connection state object to wait for the condition to
-  # shutdown the whole process.
-  state = ConnectionState()
-
-  cherrypy.config.update({
-    'server.socket_host': args.server_addr,
-    'server.socket_port': args.server_port,
-  })
-
-  WebSocketPlugin(cherrypy.engine).subscribe()
-  cherrypy.tools.websocket = WebSocketTool()
-
-  # If the cherrypy server exits for whatever reason, close the device
-  # for required cleanup. Otherwise, there might exist local/remote
-  # zombie processes.
-  cherrypy.engine.subscribe('exit',  device.close)
-
-  cherrypy.engine.signal_handler.handlers['SIGINT'] = InterruptHandler
-  cherrypy.engine.signal_handler.handlers['SIGTERM'] = InterruptHandler
-
-  x_min, x_max = device.device.RangeX()
-  y_min, y_max = device.device.RangeY()
-  p_min, p_max = device.device.RangeP()
-
-  cherrypy.quickstart(
-    Root(args.server_addr, args.server_port,
-         x_min, x_max, y_min, y_max, p_min, p_max), '',
-         config={
-           '/': {
-             'tools.staticdir.root': os.path.abspath(os.path.dirname(__file__)),
-             'tools.staticdir.on': True,
-             'tools.staticdir.dir': '',
-           },
-           '/ws': {
-             'tools.websocket.on': True,
-             'tools.websocket.handler_cls': WebplotWSHandler,
-           },
-         }
-  )
+  # Get touch snapshots from the touch device and have clients plot them.
+  webplot.GetAndPlotSnapshots()
 
 
 if __name__ == '__main__':