blob: b5a85ea97efd4f14e397edb93eaae56dede91055 [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
200 def Shutdown(self):
201 """Shutdown the cherrypy server."""
202 cherrypy.log('Shutdown timer expires. Cherrypy server for Webplot exits.')
203 cherrypy.engine.exit()
204
205
206class Root(object):
207 """A class to handle requests about docroot."""
208
209 def __init__(self, ip, port, touch_min_x, touch_max_x, touch_min_y,
210 touch_max_y, touch_min_pressure, touch_max_pressure):
211 self.ip = ip
212 self.port = port
213 self.touch_min_x = touch_min_x
214 self.touch_max_x = touch_max_x
215 self.touch_min_y = touch_min_y
216 self.touch_max_y = touch_max_y
217 self.touch_min_pressure = touch_min_pressure
218 self.touch_max_pressure = touch_max_pressure
219 self.scheme = 'ws'
220 cherrypy.log('Root address: (%s, %s)' % (ip, str(port)))
221 cherrypy.log('scheme: %s' % self.scheme)
222
223 @cherrypy.expose
224 def index(self):
225 """This is the default index.html page."""
226 websocket_dict = {
227 'websocketUrl': '%s://%s:%s/ws' % (self.scheme, self.ip, self.port),
228 'touchMinX': str(self.touch_min_x),
229 'touchMaxX': str(self.touch_max_x),
230 'touchMinY': str(self.touch_min_y),
231 'touchMaxY': str(self.touch_max_y),
232 'touchMinPressure': str(self.touch_min_pressure),
233 'touchMaxPressure': str(self.touch_max_pressure),
234 }
235 root_page = os.path.join(os.path.abspath(os.path.dirname(__file__)),
236 'webplot.html')
237 with open(root_page) as f:
238 return f.read() % websocket_dict
239
240 @cherrypy.expose
241 def ws(self):
242 """This handles the request to create a new web socket per client."""
243 cherrypy.log('A new client requesting for WS')
244 cherrypy.log('WS handler created: %s' % repr(cherrypy.request.ws_handler))
245 state.IncCount()
246
247
Joseph Hwang4782a042015-04-08 17:15:50 +0800248class Webplot(threading.Thread):
249 """The server handling the Plotting of finger traces.
250
251 Use case 1: embedding Webplot as a plotter in an application
252
253 # Instantiate a webplot server and starts the daemon.
254 plot = Webplot(server_addr, server_port, device)
255 plot.start()
256
257 # Repeatedly get a snapshot and add it for plotting.
258 while True:
259 # GetSnapshot() is essentially device.NextSnapshot()
260 snapshot = plot.GetSnapshot()
261 if not snapshot:
262 break
263 # Add the snapshot to the plotter for plotting.
264 plot.AddSnapshot(snapshot)
265
266 # Save a screen dump
267 plot.Save()
268
269 # Notify the browser to clear the screen.
270 plot.Clear()
271
272 # Notify both the browser and the cherrypy engine to quit.
273 plot.Quit()
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700274
275
Joseph Hwang4782a042015-04-08 17:15:50 +0800276 Use case 2: using webplot standalone
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700277
Joseph Hwang4782a042015-04-08 17:15:50 +0800278 # Instantiate a webplot server and starts the daemon.
279 plot = Webplot(server_addr, server_port, device)
280 plot.start()
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700281
Joseph Hwang4782a042015-04-08 17:15:50 +0800282 # Get touch snapshots from the touch device and have clients plot them.
283 webplot.GetAndPlotSnapshots()
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700284 """
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700285
Joseph Hwang4782a042015-04-08 17:15:50 +0800286 def __init__(self, server_addr, server_port, device, saved_file=SAVED_FILE):
287 self._server_addr = server_addr
288 self._server_port = server_port
289 self._device = device
290 self._saved_file = saved_file
291 super(Webplot, self).__init__(name='webplot thread')
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700292
Joseph Hwang4782a042015-04-08 17:15:50 +0800293 self.daemon = True
294 self._prev_tids = []
295
296 # Allow input traffic in iptables.
297 EnableDestinationPort(self._server_port)
298
299 # Create a ws connection state object to wait for the condition to
300 # shutdown the whole process.
301 global state
302 state = ConnectionState()
303
304 cherrypy.config.update({
305 'server.socket_host': self._server_addr,
306 'server.socket_port': self._server_port,
307 })
308
309 WebSocketPlugin(cherrypy.engine).subscribe()
310 cherrypy.tools.websocket = WebSocketTool()
311
312 # If the cherrypy server exits for whatever reason, close the device
313 # for required cleanup. Otherwise, there might exist local/remote
314 # zombie processes.
315 cherrypy.engine.subscribe('exit', self._device.close)
316
317 cherrypy.engine.signal_handler.handlers['SIGINT'] = InterruptHandler
318 cherrypy.engine.signal_handler.handlers['SIGTERM'] = InterruptHandler
319
320 def run(self):
321 """Start the cherrypy engine."""
322 x_min, x_max = self._device.device.RangeX()
323 y_min, y_max = self._device.device.RangeY()
324 p_min, p_max = self._device.device.RangeP()
325
326 cherrypy.quickstart(
327 Root(self._server_addr, self._server_port,
328 x_min, x_max, y_min, y_max, p_min, p_max),
329 '',
330 config={
331 '/': {
332 'tools.staticdir.root':
333 os.path.abspath(os.path.dirname(__file__)),
334 'tools.staticdir.on': True,
335 'tools.staticdir.dir': '',
336 },
337 '/ws': {
338 'tools.websocket.on': True,
339 'tools.websocket.handler_cls': WebplotWSHandler,
340 },
341 }
342 )
343
344 def _ConvertNamedtupleToDict(self, snapshot):
345 """Convert namedtuples to ordinary dictionaries and add leaving slots.
346
347 This is to make a snapshot json serializable. Otherwise, the namedtuples
348 would be transmitted as arrays which is less readable.
349
350 A snapshot looks like
351 MtSnapshot(
352 syn_time=1420524008.368854,
353 button_pressed=False,
354 fingers=[
355 MtFinger(tid=162, slot=0, syn_time=1420524008.368854, x=524,
356 y=231, pressure=45),
357 MtFinger(tid=163, slot=1, syn_time=1420524008.368854, x=677,
358 y=135, pressure=57)
359 ]
360 )
361
362 Note:
363 1. that there are two levels of namedtuples to convert.
364 2. The leaving slots are used to notify javascript that a finger is leaving
365 so that the corresponding finger color could be released for reuse.
366 """
367 # Convert MtSnapshot.
368 converted = dict(snapshot.__dict__.items())
369
370 # Convert MtFinger.
371 converted['fingers'] = [dict(finger.__dict__.items())
372 for finger in converted['fingers']]
373 converted['raw_events'] = [str(event) for event in converted['raw_events']]
374
375 # Add leaving fingers to notify js for reclaiming the finger colors.
376 curr_tids = [finger['tid'] for finger in converted['fingers']]
377 for tid in set(self._prev_tids) - set(curr_tids):
378 leaving_finger = {'tid': tid, 'leaving': True}
379 converted['fingers'].append(leaving_finger)
380 self._prev_tids = curr_tids
381
382 return converted
383
384 def GetSnapshot(self):
385 """Get a snapshot from the touch device."""
386 return self._device.device.NextSnapshot()
387
388 def AddSnapshot(self, snapshot):
389 """Convert the snapshot to a proper format and publish it to clients."""
390 snapshot = self._ConvertNamedtupleToDict(snapshot)
391 cherrypy.engine.publish('websocket-broadcast', json.dumps(snapshot))
392
393 def GetAndPlotSnapshots(self):
394 """Get and plot snapshots."""
395 cherrypy.log('Start getting the live stream snapshots....')
396 with open(self._saved_file, 'w') as f:
397 while True:
398 snapshot = self.GetSnapshot()
399 if not snapshot:
400 cherrypy.log('webplot is terminated.')
401 break
402 # TODO: get the raw events from the sanpshot
403 events = []
404 f.write('\n'.join(events) + '\n')
405 f.flush()
406 self.AddSnapshot(snapshot)
407
408 def Publish(self, msg):
409 """Publish a message to clients."""
410 cherrypy.engine.publish('websocket-broadcast', TextMessage(msg))
411
412 def Clear(self):
413 """Notify clients to clear the display."""
414 self.Publish('clear')
415
416 def Quit(self):
417 """Notify clients to quit.
418
419 Note that the cherrypy engine would quit accordingly.
420 """
421 self.Publish('quit')
422
423 def Save(self):
424 """Notify clients to save the screen."""
425 self.Publish('save')
426
427 def Url(self):
428 """The url the server is serving at."""
429 return 'http://%s:%d' % (self._server_addr, self._server_port)
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700430
431
432def _ParseArguments():
433 """Parse the command line options."""
434 parser = argparse.ArgumentParser(description='Webplot Server')
Charlie Mooney8026f2a2015-04-02 13:01:28 -0700435 parser.add_argument('-d', '--dut_addr', default=None,
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700436 help='the address of the dut')
Joseph Hwang59db4412015-04-09 12:43:16 +0800437
438 # Make an exclusive group to make the webplot.py command option
439 # consistent with the webplot.sh script command option.
440 # What is desired:
441 # When no command option specified in webplot.sh/webplot.py: grab is True
442 # When '--grab' option specified in webplot.sh/webplot.py: grab is True
443 # When '--nograb' option specified in webplot.sh/webplot.py: grab is False
444 grab_group = parser.add_mutually_exclusive_group()
445 grab_group.add_argument('--grab', help='grab the device exclusively',
446 action='store_true')
447 grab_group.add_argument('--nograb', help='do not grab the device',
448 action='store_true')
449
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700450 parser.add_argument('--is_touchscreen', help='the DUT is touchscreen',
451 action='store_true')
Joseph Hwang59db4412015-04-09 12:43:16 +0800452 parser.add_argument('-p', '--server_port', default=80, type=int,
453 help='the port the web server to listen to (default: 80)')
454 parser.add_argument('-s', '--server_addr', default='localhost',
455 help='the address the webplot http server listens to')
456 parser.add_argument('-t', '--dut_type', default='chromeos', type=str.lower,
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700457 help='dut type: chromeos, android')
458 args = parser.parse_args()
Joseph Hwang59db4412015-04-09 12:43:16 +0800459
460 args.grab = not args.nograb
461
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700462 return args
463
464
465def Main():
466 """The main function to launch webplot service."""
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700467 configure_logger(level=logging.DEBUG)
468 args = _ParseArguments()
469
470 print '\n' + '-' * 70
471 cherrypy.log('dut machine type: %s' % args.dut_type)
472 cherrypy.log('dut\'s touch device: %s' %
473 ('touchscreen' if args.is_touchscreen else 'touchpad'))
474 cherrypy.log('dut address: %s' % args.dut_addr)
475 cherrypy.log('web server address: %s' % args.server_addr)
476 cherrypy.log('web server port: %s' % args.server_port)
Joseph Hwang59db4412015-04-09 12:43:16 +0800477 cherrypy.log('grab the touch device: %s' % args.grab)
478 if args.dut_type == 'android' and args.grab:
479 cherrypy.log('Warning: the grab option is not supported on Android devices'
480 ' yet.')
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700481 cherrypy.log('touch events are saved in %s' % SAVED_FILE)
482 print '-' * 70 + '\n\n'
483
484 if args.server_port == 80:
485 url = args.server_addr
486 else:
487 url = '%s:%d' % (args.server_addr, args.server_port)
488
489 msg = 'Type "%s" in browser %s to see finger traces.\n'
490 if args.server_addr == 'localhost':
491 which_machine = 'on the webplot server machine'
492 else:
493 which_machine = 'on any machine'
494
495 print '*' * 70
496 print msg % (url, which_machine)
497 print 'Press \'q\' on the browser to quit.'
498 print '*' * 70 + '\n\n'
499
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700500 # Instantiate a touch device.
Joseph Hwang59db4412015-04-09 12:43:16 +0800501 device = TouchDeviceWrapper(args.dut_type, args.dut_addr, args.is_touchscreen,
502 args.grab)
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700503
Joseph Hwang4782a042015-04-08 17:15:50 +0800504 # Instantiate a webplot server daemon and start it.
505 webplot = Webplot(args.server_addr, args.server_port, device)
506 webplot.start()
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700507
Joseph Hwang4782a042015-04-08 17:15:50 +0800508 # Get touch snapshots from the touch device and have clients plot them.
509 webplot.GetAndPlotSnapshots()
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700510
511
512if __name__ == '__main__':
513 Main()