blob: 4bba3b2d3794c428d9dc742af13dad27fdbf84b6 [file] [log] [blame]
Charlie Mooneybbc05f52015-03-24 13:36:22 -07001# Copyright (c) 2014 The Chromium OS Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5"""This module launches the cherrypy server for webplot and sets up
6web sockets to handle the messages between the clients and the server.
7"""
8
9import argparse
10import base64
11import json
12import logging
13import os
14import re
15import subprocess
16import threading
17
18import cherrypy
19
Charlie Mooneybbc05f52015-03-24 13:36:22 -070020from ws4py import configure_logger
21from ws4py.messaging import TextMessage
22from ws4py.server.cherrypyserver import WebSocketPlugin, WebSocketTool
23from ws4py.websocket import WebSocket
24
Charlie Mooney04b41532015-04-02 12:41:37 -070025from remote import ChromeOSTouchDevice, AndroidTouchDevice
Charlie Mooneybbc05f52015-03-24 13:36:22 -070026
27
28# The WebSocket connection state object.
29state = None
30
31# The touch events are saved in this file as default.
32SAVED_FILE = '/tmp/webplot.dat'
33SAVED_IMAGE = '/tmp/webplot.png'
34
35
Joseph Hwang4782a042015-04-08 17:15:50 +080036def SimpleSystem(cmd):
37 """Execute a system command."""
38 ret = subprocess.call(cmd, shell=True)
39 if ret:
40 logging.warning('Command (%s) failed (ret=%s).', cmd, ret)
41 return ret
42
43
44def SimpleSystemOutput(cmd):
45 """Execute a system command and get its output."""
46 try:
47 proc = subprocess.Popen(
48 cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
49 stdout, _ = proc.communicate()
50 except Exception, e:
51 logging.warning('Command (%s) failed (%s).', cmd, e)
52 else:
53 return None if proc.returncode else stdout.strip()
54
55
56def IsDestinationPortEnabled(port):
57 """Check if the destination port is enabled in iptables.
58
59 If port 8000 is enabled, it looks like
60 ACCEPT tcp -- 0.0.0.0/0 0.0.0.0/0 ctstate NEW tcp dpt:8000
61 """
62 pattern = re.compile('ACCEPT\s+tcp.+\s+ctstate\s+NEW\s+tcp\s+dpt:%d' % port)
63 rules = SimpleSystemOutput('sudo iptables -L INPUT -n --line-number')
64 for rule in rules.splitlines():
65 if pattern.search(rule):
66 return True
67 return False
68
69
70def EnableDestinationPort(port):
71 """Enable the destination port for input traffic in iptables."""
72 if IsDestinationPortEnabled(port):
73 cherrypy.log('Port %d has been already enabled in iptables.' % port)
74 else:
75 cherrypy.log('To enable port %d in iptables.' % port)
76 cmd = ('sudo iptables -A INPUT -p tcp -m conntrack --ctstate NEW '
77 '--dport %d -j ACCEPT' % port)
78 if SimpleSystem(cmd) != 0:
79 raise Error('Failed to enable port in iptables: %d.' % port)
80
81
Charlie Mooneybbc05f52015-03-24 13:36:22 -070082def InterruptHandler():
83 """An interrupt handler for both SIGINT and SIGTERM
84
85 The stop procedure triggered is as follows:
86 1. This handler sends a 'quit' message to the listening client.
87 2. The client sends the canvas image back to the server in its quit message.
88 3. WebplotWSHandler.received_message() saves the image.
89 4. WebplotWSHandler.received_message() handles the 'quit' message.
90 The cherrypy engine exits if this is the last client.
91 """
92 cherrypy.log('Cherrypy engine is sending quit message to clients.')
93 cherrypy.engine.publish('websocket-broadcast', TextMessage('quit'))
94
95
96class WebplotWSHandler(WebSocket):
97 """The web socket handler for webplot."""
98
99 def opened(self):
100 """This method is called when the handler is opened."""
101 cherrypy.log('WS handler is opened!')
102
103 def received_message(self, msg):
104 """A callback for received message."""
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700105 data = msg.data.split(':', 1)
106 mtype = data[0].lower()
107 content = data[1] if len(data) == 2 else None
Joseph Hwang0c1fa7d2015-04-09 17:01:45 +0800108
109 # Do not print the image data since it is too large.
110 if mtype != 'save':
111 cherrypy.log('Received message: %s' % str(msg.data))
112
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700113 if mtype == 'quit':
114 # A shutdown message requested by the user.
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700115 cherrypy.log('The user requests to shutdown the cherrypy server....')
116 state.DecCount()
117 elif mtype == 'save':
Charlie Mooneyb476e892015-04-02 13:25:49 -0700118 cherrypy.log('All data saved to "%s"' % SAVED_FILE)
119 self.SaveImage(content, SAVED_IMAGE)
120 cherrypy.log('Plot image saved to "%s"' % SAVED_IMAGE)
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700121 else:
122 cherrypy.log('Unknown message type: %s' % mtype)
123
124 def closed(self, code, reason="A client left the room."):
125 """This method is called when the handler is closed."""
126 cherrypy.log('A client requests to close WS.')
127 cherrypy.engine.publish('websocket-broadcast', TextMessage(reason))
128
129 @staticmethod
130 def SaveImage(image_data, image_file):
131 """Decoded the base64 image data and save it in the file."""
132 with open(image_file, 'w') as f:
133 f.write(base64.b64decode(image_data))
134
135
136class TouchDeviceWrapper(object):
137 """This is a wrapper of remote.RemoteTouchDevice.
138
139 It handles the instantiation of different device types, and the beginning
140 and ending of the event stream.
141 """
142
Joseph Hwang59db4412015-04-09 12:43:16 +0800143 def __init__(self, dut_type, addr, is_touchscreen, grab):
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700144 if dut_type == 'chromeos':
Charlie Mooney8026f2a2015-04-02 13:01:28 -0700145 if addr is None:
146 addr = '127.0.0.1'
Joseph Hwang59db4412015-04-09 12:43:16 +0800147 self.device = ChromeOSTouchDevice(addr, is_touchscreen, grab=grab)
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700148 else:
149 self.device = AndroidTouchDevice(addr, True)
150
151 def close(self):
152 """ Close the device gracefully. """
153 if self.device.event_stream_process:
154 self.device.__del__()
155
156 def __str__(self):
157 return '\n '.join(sorted([str(slot) for slot in self.slots.values()]))
158
159
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700160class ConnectionState(object):
161 """A ws connection state object for shutting down the cherrypy server.
162
163 It shuts down the cherrypy server when the count is down to 0 and is not
164 increased before the shutdown_timer expires.
165
166 Note that when a page refreshes, it closes the WS connection first and
167 then re-connects immediately. This is why we would like to wait a while
168 before actually shutting down the server.
169 """
170 TIMEOUT = 1.0
171
172 def __init__(self):
173 self.count = 0;
174 self.lock = threading.Lock()
175 self.shutdown_timer = None
176
177 def IncCount(self):
178 """Increase the connection count, and cancel the shutdown timer if exists.
179 """
180 self.lock.acquire()
181 self.count += 1;
182 cherrypy.log(' WS connection count: %d' % self.count)
183 if self.shutdown_timer:
184 self.shutdown_timer.cancel()
185 self.shutdown_timer = None
186 self.lock.release()
187
188 def DecCount(self):
189 """Decrease the connection count, and start a shutdown timer if no other
190 clients are connecting to the server.
191 """
192 self.lock.acquire()
193 self.count -= 1;
194 cherrypy.log(' WS connection count: %d' % self.count)
195 if self.count == 0:
196 self.shutdown_timer = threading.Timer(self.TIMEOUT, self.Shutdown)
197 self.shutdown_timer.start()
198 self.lock.release()
199
Joseph Hwang3f561d32015-04-09 16:33:56 +0800200 def ShutdownWhenNoConnections(self):
201 """Shutdown cherrypy server when there is no client connection."""
202 self.lock.acquire()
203 if self.count == 0 and self.shutdown_timer is None:
204 self.Shutdown()
205 self.lock.release()
206
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700207 def Shutdown(self):
208 """Shutdown the cherrypy server."""
209 cherrypy.log('Shutdown timer expires. Cherrypy server for Webplot exits.')
210 cherrypy.engine.exit()
211
212
213class Root(object):
214 """A class to handle requests about docroot."""
215
216 def __init__(self, ip, port, touch_min_x, touch_max_x, touch_min_y,
217 touch_max_y, touch_min_pressure, touch_max_pressure):
218 self.ip = ip
219 self.port = port
220 self.touch_min_x = touch_min_x
221 self.touch_max_x = touch_max_x
222 self.touch_min_y = touch_min_y
223 self.touch_max_y = touch_max_y
224 self.touch_min_pressure = touch_min_pressure
225 self.touch_max_pressure = touch_max_pressure
226 self.scheme = 'ws'
227 cherrypy.log('Root address: (%s, %s)' % (ip, str(port)))
228 cherrypy.log('scheme: %s' % self.scheme)
229
230 @cherrypy.expose
231 def index(self):
232 """This is the default index.html page."""
233 websocket_dict = {
234 'websocketUrl': '%s://%s:%s/ws' % (self.scheme, self.ip, self.port),
235 'touchMinX': str(self.touch_min_x),
236 'touchMaxX': str(self.touch_max_x),
237 'touchMinY': str(self.touch_min_y),
238 'touchMaxY': str(self.touch_max_y),
239 'touchMinPressure': str(self.touch_min_pressure),
240 'touchMaxPressure': str(self.touch_max_pressure),
241 }
242 root_page = os.path.join(os.path.abspath(os.path.dirname(__file__)),
243 'webplot.html')
244 with open(root_page) as f:
245 return f.read() % websocket_dict
246
247 @cherrypy.expose
248 def ws(self):
249 """This handles the request to create a new web socket per client."""
250 cherrypy.log('A new client requesting for WS')
251 cherrypy.log('WS handler created: %s' % repr(cherrypy.request.ws_handler))
252 state.IncCount()
253
254
Joseph Hwang4782a042015-04-08 17:15:50 +0800255class Webplot(threading.Thread):
256 """The server handling the Plotting of finger traces.
257
258 Use case 1: embedding Webplot as a plotter in an application
259
260 # Instantiate a webplot server and starts the daemon.
261 plot = Webplot(server_addr, server_port, device)
262 plot.start()
263
264 # Repeatedly get a snapshot and add it for plotting.
265 while True:
266 # GetSnapshot() is essentially device.NextSnapshot()
267 snapshot = plot.GetSnapshot()
268 if not snapshot:
269 break
270 # Add the snapshot to the plotter for plotting.
271 plot.AddSnapshot(snapshot)
272
273 # Save a screen dump
274 plot.Save()
275
276 # Notify the browser to clear the screen.
277 plot.Clear()
278
279 # Notify both the browser and the cherrypy engine to quit.
280 plot.Quit()
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700281
282
Joseph Hwang4782a042015-04-08 17:15:50 +0800283 Use case 2: using webplot standalone
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700284
Joseph Hwang4782a042015-04-08 17:15:50 +0800285 # Instantiate a webplot server and starts the daemon.
286 plot = Webplot(server_addr, server_port, device)
287 plot.start()
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700288
Joseph Hwang4782a042015-04-08 17:15:50 +0800289 # Get touch snapshots from the touch device and have clients plot them.
290 webplot.GetAndPlotSnapshots()
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700291 """
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700292
Joseph Hwang4782a042015-04-08 17:15:50 +0800293 def __init__(self, server_addr, server_port, device, saved_file=SAVED_FILE):
294 self._server_addr = server_addr
295 self._server_port = server_port
296 self._device = device
297 self._saved_file = saved_file
298 super(Webplot, self).__init__(name='webplot thread')
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700299
Joseph Hwang4782a042015-04-08 17:15:50 +0800300 self.daemon = True
301 self._prev_tids = []
302
303 # Allow input traffic in iptables.
304 EnableDestinationPort(self._server_port)
305
306 # Create a ws connection state object to wait for the condition to
307 # shutdown the whole process.
308 global state
309 state = ConnectionState()
310
311 cherrypy.config.update({
312 'server.socket_host': self._server_addr,
313 'server.socket_port': self._server_port,
314 })
315
316 WebSocketPlugin(cherrypy.engine).subscribe()
317 cherrypy.tools.websocket = WebSocketTool()
318
319 # If the cherrypy server exits for whatever reason, close the device
320 # for required cleanup. Otherwise, there might exist local/remote
321 # zombie processes.
322 cherrypy.engine.subscribe('exit', self._device.close)
323
324 cherrypy.engine.signal_handler.handlers['SIGINT'] = InterruptHandler
325 cherrypy.engine.signal_handler.handlers['SIGTERM'] = InterruptHandler
326
327 def run(self):
328 """Start the cherrypy engine."""
329 x_min, x_max = self._device.device.RangeX()
330 y_min, y_max = self._device.device.RangeY()
331 p_min, p_max = self._device.device.RangeP()
332
333 cherrypy.quickstart(
334 Root(self._server_addr, self._server_port,
335 x_min, x_max, y_min, y_max, p_min, p_max),
336 '',
337 config={
338 '/': {
339 'tools.staticdir.root':
340 os.path.abspath(os.path.dirname(__file__)),
341 'tools.staticdir.on': True,
342 'tools.staticdir.dir': '',
343 },
344 '/ws': {
345 'tools.websocket.on': True,
346 'tools.websocket.handler_cls': WebplotWSHandler,
347 },
348 }
349 )
350
351 def _ConvertNamedtupleToDict(self, snapshot):
352 """Convert namedtuples to ordinary dictionaries and add leaving slots.
353
354 This is to make a snapshot json serializable. Otherwise, the namedtuples
355 would be transmitted as arrays which is less readable.
356
357 A snapshot looks like
358 MtSnapshot(
359 syn_time=1420524008.368854,
360 button_pressed=False,
361 fingers=[
362 MtFinger(tid=162, slot=0, syn_time=1420524008.368854, x=524,
363 y=231, pressure=45),
364 MtFinger(tid=163, slot=1, syn_time=1420524008.368854, x=677,
365 y=135, pressure=57)
366 ]
367 )
368
369 Note:
370 1. that there are two levels of namedtuples to convert.
371 2. The leaving slots are used to notify javascript that a finger is leaving
372 so that the corresponding finger color could be released for reuse.
373 """
374 # Convert MtSnapshot.
375 converted = dict(snapshot.__dict__.items())
376
377 # Convert MtFinger.
378 converted['fingers'] = [dict(finger.__dict__.items())
379 for finger in converted['fingers']]
380 converted['raw_events'] = [str(event) for event in converted['raw_events']]
381
382 # Add leaving fingers to notify js for reclaiming the finger colors.
383 curr_tids = [finger['tid'] for finger in converted['fingers']]
384 for tid in set(self._prev_tids) - set(curr_tids):
385 leaving_finger = {'tid': tid, 'leaving': True}
386 converted['fingers'].append(leaving_finger)
387 self._prev_tids = curr_tids
388
389 return converted
390
391 def GetSnapshot(self):
392 """Get a snapshot from the touch device."""
393 return self._device.device.NextSnapshot()
394
395 def AddSnapshot(self, snapshot):
396 """Convert the snapshot to a proper format and publish it to clients."""
397 snapshot = self._ConvertNamedtupleToDict(snapshot)
398 cherrypy.engine.publish('websocket-broadcast', json.dumps(snapshot))
399
400 def GetAndPlotSnapshots(self):
401 """Get and plot snapshots."""
402 cherrypy.log('Start getting the live stream snapshots....')
403 with open(self._saved_file, 'w') as f:
404 while True:
Joseph Hwang3f561d32015-04-09 16:33:56 +0800405 try:
406 snapshot = self.GetSnapshot()
407 if not snapshot:
408 cherrypy.log('webplot is terminated.')
409 break
410 # TODO: get the raw events from the sanpshot
411 events = []
412 f.write('\n'.join(events) + '\n')
413 f.flush()
414 self.AddSnapshot(snapshot)
415 except KeyboardInterrupt:
416 cherrypy.log('Keyboard Interrupt accepted')
417 cherrypy.log('webplot is being terminated...')
418 # Notify the clients to quit.
419 self.Publish('quit')
420 # If there is no client connection, the cherrypy server just exits.
421 state.ShutdownWhenNoConnections()
Joseph Hwang4782a042015-04-08 17:15:50 +0800422
423 def Publish(self, msg):
424 """Publish a message to clients."""
425 cherrypy.engine.publish('websocket-broadcast', TextMessage(msg))
426
427 def Clear(self):
428 """Notify clients to clear the display."""
429 self.Publish('clear')
430
431 def Quit(self):
432 """Notify clients to quit.
433
434 Note that the cherrypy engine would quit accordingly.
435 """
436 self.Publish('quit')
437
438 def Save(self):
439 """Notify clients to save the screen."""
440 self.Publish('save')
441
442 def Url(self):
443 """The url the server is serving at."""
444 return 'http://%s:%d' % (self._server_addr, self._server_port)
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700445
446
447def _ParseArguments():
448 """Parse the command line options."""
449 parser = argparse.ArgumentParser(description='Webplot Server')
Charlie Mooney8026f2a2015-04-02 13:01:28 -0700450 parser.add_argument('-d', '--dut_addr', default=None,
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700451 help='the address of the dut')
Joseph Hwang59db4412015-04-09 12:43:16 +0800452
453 # Make an exclusive group to make the webplot.py command option
454 # consistent with the webplot.sh script command option.
455 # What is desired:
456 # When no command option specified in webplot.sh/webplot.py: grab is True
457 # When '--grab' option specified in webplot.sh/webplot.py: grab is True
458 # When '--nograb' option specified in webplot.sh/webplot.py: grab is False
459 grab_group = parser.add_mutually_exclusive_group()
460 grab_group.add_argument('--grab', help='grab the device exclusively',
461 action='store_true')
462 grab_group.add_argument('--nograb', help='do not grab the device',
463 action='store_true')
464
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700465 parser.add_argument('--is_touchscreen', help='the DUT is touchscreen',
466 action='store_true')
Joseph Hwang59db4412015-04-09 12:43:16 +0800467 parser.add_argument('-p', '--server_port', default=80, type=int,
468 help='the port the web server to listen to (default: 80)')
469 parser.add_argument('-s', '--server_addr', default='localhost',
470 help='the address the webplot http server listens to')
471 parser.add_argument('-t', '--dut_type', default='chromeos', type=str.lower,
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700472 help='dut type: chromeos, android')
473 args = parser.parse_args()
Joseph Hwang59db4412015-04-09 12:43:16 +0800474
475 args.grab = not args.nograb
476
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700477 return args
478
479
480def Main():
481 """The main function to launch webplot service."""
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700482 configure_logger(level=logging.DEBUG)
483 args = _ParseArguments()
484
485 print '\n' + '-' * 70
486 cherrypy.log('dut machine type: %s' % args.dut_type)
487 cherrypy.log('dut\'s touch device: %s' %
488 ('touchscreen' if args.is_touchscreen else 'touchpad'))
489 cherrypy.log('dut address: %s' % args.dut_addr)
490 cherrypy.log('web server address: %s' % args.server_addr)
491 cherrypy.log('web server port: %s' % args.server_port)
Joseph Hwang59db4412015-04-09 12:43:16 +0800492 cherrypy.log('grab the touch device: %s' % args.grab)
493 if args.dut_type == 'android' and args.grab:
494 cherrypy.log('Warning: the grab option is not supported on Android devices'
495 ' yet.')
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700496 cherrypy.log('touch events are saved in %s' % SAVED_FILE)
497 print '-' * 70 + '\n\n'
498
499 if args.server_port == 80:
500 url = args.server_addr
501 else:
502 url = '%s:%d' % (args.server_addr, args.server_port)
503
504 msg = 'Type "%s" in browser %s to see finger traces.\n'
505 if args.server_addr == 'localhost':
506 which_machine = 'on the webplot server machine'
507 else:
508 which_machine = 'on any machine'
509
510 print '*' * 70
511 print msg % (url, which_machine)
512 print 'Press \'q\' on the browser to quit.'
513 print '*' * 70 + '\n\n'
514
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700515 # Instantiate a touch device.
Joseph Hwang59db4412015-04-09 12:43:16 +0800516 device = TouchDeviceWrapper(args.dut_type, args.dut_addr, args.is_touchscreen,
517 args.grab)
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700518
Joseph Hwang4782a042015-04-08 17:15:50 +0800519 # Instantiate a webplot server daemon and start it.
520 webplot = Webplot(args.server_addr, args.server_port, device)
521 webplot.start()
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700522
Joseph Hwang4782a042015-04-08 17:15:50 +0800523 # Get touch snapshots from the touch device and have clients plot them.
524 webplot.GetAndPlotSnapshots()
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700525
526
527if __name__ == '__main__':
528 Main()