blob: af62e9b586e57e604170132f87f51992374bf1b4 [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."""
105 cherrypy.log('Received message: %s' % str(msg.data))
106 data = msg.data.split(':', 1)
107 mtype = data[0].lower()
108 content = data[1] if len(data) == 2 else None
109 if mtype == 'quit':
110 # A shutdown message requested by the user.
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700111 cherrypy.log('The user requests to shutdown the cherrypy server....')
112 state.DecCount()
113 elif mtype == 'save':
Charlie Mooneyb476e892015-04-02 13:25:49 -0700114 cherrypy.log('All data saved to "%s"' % SAVED_FILE)
115 self.SaveImage(content, SAVED_IMAGE)
116 cherrypy.log('Plot image saved to "%s"' % SAVED_IMAGE)
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700117 else:
118 cherrypy.log('Unknown message type: %s' % mtype)
119
120 def closed(self, code, reason="A client left the room."):
121 """This method is called when the handler is closed."""
122 cherrypy.log('A client requests to close WS.')
123 cherrypy.engine.publish('websocket-broadcast', TextMessage(reason))
124
125 @staticmethod
126 def SaveImage(image_data, image_file):
127 """Decoded the base64 image data and save it in the file."""
128 with open(image_file, 'w') as f:
129 f.write(base64.b64decode(image_data))
130
131
132class TouchDeviceWrapper(object):
133 """This is a wrapper of remote.RemoteTouchDevice.
134
135 It handles the instantiation of different device types, and the beginning
136 and ending of the event stream.
137 """
138
139 def __init__(self, dut_type, addr, is_touchscreen):
140 if dut_type == 'chromeos':
Charlie Mooney8026f2a2015-04-02 13:01:28 -0700141 if addr is None:
142 addr = '127.0.0.1'
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700143 self.device = ChromeOSTouchDevice(addr, is_touchscreen)
144 else:
145 self.device = AndroidTouchDevice(addr, True)
146
147 def close(self):
148 """ Close the device gracefully. """
149 if self.device.event_stream_process:
150 self.device.__del__()
151
152 def __str__(self):
153 return '\n '.join(sorted([str(slot) for slot in self.slots.values()]))
154
155
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700156class ConnectionState(object):
157 """A ws connection state object for shutting down the cherrypy server.
158
159 It shuts down the cherrypy server when the count is down to 0 and is not
160 increased before the shutdown_timer expires.
161
162 Note that when a page refreshes, it closes the WS connection first and
163 then re-connects immediately. This is why we would like to wait a while
164 before actually shutting down the server.
165 """
166 TIMEOUT = 1.0
167
168 def __init__(self):
169 self.count = 0;
170 self.lock = threading.Lock()
171 self.shutdown_timer = None
172
173 def IncCount(self):
174 """Increase the connection count, and cancel the shutdown timer if exists.
175 """
176 self.lock.acquire()
177 self.count += 1;
178 cherrypy.log(' WS connection count: %d' % self.count)
179 if self.shutdown_timer:
180 self.shutdown_timer.cancel()
181 self.shutdown_timer = None
182 self.lock.release()
183
184 def DecCount(self):
185 """Decrease the connection count, and start a shutdown timer if no other
186 clients are connecting to the server.
187 """
188 self.lock.acquire()
189 self.count -= 1;
190 cherrypy.log(' WS connection count: %d' % self.count)
191 if self.count == 0:
192 self.shutdown_timer = threading.Timer(self.TIMEOUT, self.Shutdown)
193 self.shutdown_timer.start()
194 self.lock.release()
195
196 def Shutdown(self):
197 """Shutdown the cherrypy server."""
198 cherrypy.log('Shutdown timer expires. Cherrypy server for Webplot exits.')
199 cherrypy.engine.exit()
200
201
202class Root(object):
203 """A class to handle requests about docroot."""
204
205 def __init__(self, ip, port, touch_min_x, touch_max_x, touch_min_y,
206 touch_max_y, touch_min_pressure, touch_max_pressure):
207 self.ip = ip
208 self.port = port
209 self.touch_min_x = touch_min_x
210 self.touch_max_x = touch_max_x
211 self.touch_min_y = touch_min_y
212 self.touch_max_y = touch_max_y
213 self.touch_min_pressure = touch_min_pressure
214 self.touch_max_pressure = touch_max_pressure
215 self.scheme = 'ws'
216 cherrypy.log('Root address: (%s, %s)' % (ip, str(port)))
217 cherrypy.log('scheme: %s' % self.scheme)
218
219 @cherrypy.expose
220 def index(self):
221 """This is the default index.html page."""
222 websocket_dict = {
223 'websocketUrl': '%s://%s:%s/ws' % (self.scheme, self.ip, self.port),
224 'touchMinX': str(self.touch_min_x),
225 'touchMaxX': str(self.touch_max_x),
226 'touchMinY': str(self.touch_min_y),
227 'touchMaxY': str(self.touch_max_y),
228 'touchMinPressure': str(self.touch_min_pressure),
229 'touchMaxPressure': str(self.touch_max_pressure),
230 }
231 root_page = os.path.join(os.path.abspath(os.path.dirname(__file__)),
232 'webplot.html')
233 with open(root_page) as f:
234 return f.read() % websocket_dict
235
236 @cherrypy.expose
237 def ws(self):
238 """This handles the request to create a new web socket per client."""
239 cherrypy.log('A new client requesting for WS')
240 cherrypy.log('WS handler created: %s' % repr(cherrypy.request.ws_handler))
241 state.IncCount()
242
243
Joseph Hwang4782a042015-04-08 17:15:50 +0800244class Webplot(threading.Thread):
245 """The server handling the Plotting of finger traces.
246
247 Use case 1: embedding Webplot as a plotter in an application
248
249 # Instantiate a webplot server and starts the daemon.
250 plot = Webplot(server_addr, server_port, device)
251 plot.start()
252
253 # Repeatedly get a snapshot and add it for plotting.
254 while True:
255 # GetSnapshot() is essentially device.NextSnapshot()
256 snapshot = plot.GetSnapshot()
257 if not snapshot:
258 break
259 # Add the snapshot to the plotter for plotting.
260 plot.AddSnapshot(snapshot)
261
262 # Save a screen dump
263 plot.Save()
264
265 # Notify the browser to clear the screen.
266 plot.Clear()
267
268 # Notify both the browser and the cherrypy engine to quit.
269 plot.Quit()
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700270
271
Joseph Hwang4782a042015-04-08 17:15:50 +0800272 Use case 2: using webplot standalone
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700273
Joseph Hwang4782a042015-04-08 17:15:50 +0800274 # Instantiate a webplot server and starts the daemon.
275 plot = Webplot(server_addr, server_port, device)
276 plot.start()
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700277
Joseph Hwang4782a042015-04-08 17:15:50 +0800278 # Get touch snapshots from the touch device and have clients plot them.
279 webplot.GetAndPlotSnapshots()
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700280 """
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700281
Joseph Hwang4782a042015-04-08 17:15:50 +0800282 def __init__(self, server_addr, server_port, device, saved_file=SAVED_FILE):
283 self._server_addr = server_addr
284 self._server_port = server_port
285 self._device = device
286 self._saved_file = saved_file
287 super(Webplot, self).__init__(name='webplot thread')
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700288
Joseph Hwang4782a042015-04-08 17:15:50 +0800289 self.daemon = True
290 self._prev_tids = []
291
292 # Allow input traffic in iptables.
293 EnableDestinationPort(self._server_port)
294
295 # Create a ws connection state object to wait for the condition to
296 # shutdown the whole process.
297 global state
298 state = ConnectionState()
299
300 cherrypy.config.update({
301 'server.socket_host': self._server_addr,
302 'server.socket_port': self._server_port,
303 })
304
305 WebSocketPlugin(cherrypy.engine).subscribe()
306 cherrypy.tools.websocket = WebSocketTool()
307
308 # If the cherrypy server exits for whatever reason, close the device
309 # for required cleanup. Otherwise, there might exist local/remote
310 # zombie processes.
311 cherrypy.engine.subscribe('exit', self._device.close)
312
313 cherrypy.engine.signal_handler.handlers['SIGINT'] = InterruptHandler
314 cherrypy.engine.signal_handler.handlers['SIGTERM'] = InterruptHandler
315
316 def run(self):
317 """Start the cherrypy engine."""
318 x_min, x_max = self._device.device.RangeX()
319 y_min, y_max = self._device.device.RangeY()
320 p_min, p_max = self._device.device.RangeP()
321
322 cherrypy.quickstart(
323 Root(self._server_addr, self._server_port,
324 x_min, x_max, y_min, y_max, p_min, p_max),
325 '',
326 config={
327 '/': {
328 'tools.staticdir.root':
329 os.path.abspath(os.path.dirname(__file__)),
330 'tools.staticdir.on': True,
331 'tools.staticdir.dir': '',
332 },
333 '/ws': {
334 'tools.websocket.on': True,
335 'tools.websocket.handler_cls': WebplotWSHandler,
336 },
337 }
338 )
339
340 def _ConvertNamedtupleToDict(self, snapshot):
341 """Convert namedtuples to ordinary dictionaries and add leaving slots.
342
343 This is to make a snapshot json serializable. Otherwise, the namedtuples
344 would be transmitted as arrays which is less readable.
345
346 A snapshot looks like
347 MtSnapshot(
348 syn_time=1420524008.368854,
349 button_pressed=False,
350 fingers=[
351 MtFinger(tid=162, slot=0, syn_time=1420524008.368854, x=524,
352 y=231, pressure=45),
353 MtFinger(tid=163, slot=1, syn_time=1420524008.368854, x=677,
354 y=135, pressure=57)
355 ]
356 )
357
358 Note:
359 1. that there are two levels of namedtuples to convert.
360 2. The leaving slots are used to notify javascript that a finger is leaving
361 so that the corresponding finger color could be released for reuse.
362 """
363 # Convert MtSnapshot.
364 converted = dict(snapshot.__dict__.items())
365
366 # Convert MtFinger.
367 converted['fingers'] = [dict(finger.__dict__.items())
368 for finger in converted['fingers']]
369 converted['raw_events'] = [str(event) for event in converted['raw_events']]
370
371 # Add leaving fingers to notify js for reclaiming the finger colors.
372 curr_tids = [finger['tid'] for finger in converted['fingers']]
373 for tid in set(self._prev_tids) - set(curr_tids):
374 leaving_finger = {'tid': tid, 'leaving': True}
375 converted['fingers'].append(leaving_finger)
376 self._prev_tids = curr_tids
377
378 return converted
379
380 def GetSnapshot(self):
381 """Get a snapshot from the touch device."""
382 return self._device.device.NextSnapshot()
383
384 def AddSnapshot(self, snapshot):
385 """Convert the snapshot to a proper format and publish it to clients."""
386 snapshot = self._ConvertNamedtupleToDict(snapshot)
387 cherrypy.engine.publish('websocket-broadcast', json.dumps(snapshot))
388
389 def GetAndPlotSnapshots(self):
390 """Get and plot snapshots."""
391 cherrypy.log('Start getting the live stream snapshots....')
392 with open(self._saved_file, 'w') as f:
393 while True:
394 snapshot = self.GetSnapshot()
395 if not snapshot:
396 cherrypy.log('webplot is terminated.')
397 break
398 # TODO: get the raw events from the sanpshot
399 events = []
400 f.write('\n'.join(events) + '\n')
401 f.flush()
402 self.AddSnapshot(snapshot)
403
404 def Publish(self, msg):
405 """Publish a message to clients."""
406 cherrypy.engine.publish('websocket-broadcast', TextMessage(msg))
407
408 def Clear(self):
409 """Notify clients to clear the display."""
410 self.Publish('clear')
411
412 def Quit(self):
413 """Notify clients to quit.
414
415 Note that the cherrypy engine would quit accordingly.
416 """
417 self.Publish('quit')
418
419 def Save(self):
420 """Notify clients to save the screen."""
421 self.Publish('save')
422
423 def Url(self):
424 """The url the server is serving at."""
425 return 'http://%s:%d' % (self._server_addr, self._server_port)
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700426
427
428def _ParseArguments():
429 """Parse the command line options."""
430 parser = argparse.ArgumentParser(description='Webplot Server')
Charlie Mooney8026f2a2015-04-02 13:01:28 -0700431 parser.add_argument('-d', '--dut_addr', default=None,
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700432 help='the address of the dut')
433 parser.add_argument('-s', '--server_addr', default='localhost',
434 help='the address the webplot http server listens to')
435 parser.add_argument('-p', '--server_port', default=80, type=int,
436 help='the port the web server to listen to (default: 80)')
437 parser.add_argument('--is_touchscreen', help='the DUT is touchscreen',
438 action='store_true')
439 parser.add_argument('-t', '--dut_type', default='chromeos',
440 help='dut type: chromeos, android')
441 args = parser.parse_args()
442 return args
443
444
445def Main():
446 """The main function to launch webplot service."""
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700447 configure_logger(level=logging.DEBUG)
448 args = _ParseArguments()
449
450 print '\n' + '-' * 70
451 cherrypy.log('dut machine type: %s' % args.dut_type)
452 cherrypy.log('dut\'s touch device: %s' %
453 ('touchscreen' if args.is_touchscreen else 'touchpad'))
454 cherrypy.log('dut address: %s' % args.dut_addr)
455 cherrypy.log('web server address: %s' % args.server_addr)
456 cherrypy.log('web server port: %s' % args.server_port)
457 cherrypy.log('touch events are saved in %s' % SAVED_FILE)
458 print '-' * 70 + '\n\n'
459
460 if args.server_port == 80:
461 url = args.server_addr
462 else:
463 url = '%s:%d' % (args.server_addr, args.server_port)
464
465 msg = 'Type "%s" in browser %s to see finger traces.\n'
466 if args.server_addr == 'localhost':
467 which_machine = 'on the webplot server machine'
468 else:
469 which_machine = 'on any machine'
470
471 print '*' * 70
472 print msg % (url, which_machine)
473 print 'Press \'q\' on the browser to quit.'
474 print '*' * 70 + '\n\n'
475
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700476 # Instantiate a touch device.
477 device = TouchDeviceWrapper(args.dut_type, args.dut_addr, args.is_touchscreen)
478
Joseph Hwang4782a042015-04-08 17:15:50 +0800479 # Instantiate a webplot server daemon and start it.
480 webplot = Webplot(args.server_addr, args.server_port, device)
481 webplot.start()
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700482
Joseph Hwang4782a042015-04-08 17:15:50 +0800483 # Get touch snapshots from the touch device and have clients plot them.
484 webplot.GetAndPlotSnapshots()
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700485
486
487if __name__ == '__main__':
488 Main()