blob: 55026bb6d7df1ce1fe696708707e3970df723579 [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
36def InterruptHandler():
37 """An interrupt handler for both SIGINT and SIGTERM
38
39 The stop procedure triggered is as follows:
40 1. This handler sends a 'quit' message to the listening client.
41 2. The client sends the canvas image back to the server in its quit message.
42 3. WebplotWSHandler.received_message() saves the image.
43 4. WebplotWSHandler.received_message() handles the 'quit' message.
44 The cherrypy engine exits if this is the last client.
45 """
46 cherrypy.log('Cherrypy engine is sending quit message to clients.')
47 cherrypy.engine.publish('websocket-broadcast', TextMessage('quit'))
48
49
50class WebplotWSHandler(WebSocket):
51 """The web socket handler for webplot."""
52
53 def opened(self):
54 """This method is called when the handler is opened."""
55 cherrypy.log('WS handler is opened!')
56
57 def received_message(self, msg):
58 """A callback for received message."""
59 cherrypy.log('Received message: %s' % str(msg.data))
60 data = msg.data.split(':', 1)
61 mtype = data[0].lower()
62 content = data[1] if len(data) == 2 else None
63 if mtype == 'quit':
64 # A shutdown message requested by the user.
65 cherrypy.log('Save the image to %s' % SAVED_IMAGE)
66 self.SaveImage(content, SAVED_IMAGE)
67 cherrypy.log('The user requests to shutdown the cherrypy server....')
68 state.DecCount()
69 elif mtype == 'save':
70 cherrypy.log('Save data to %s' % content)
71 else:
72 cherrypy.log('Unknown message type: %s' % mtype)
73
74 def closed(self, code, reason="A client left the room."):
75 """This method is called when the handler is closed."""
76 cherrypy.log('A client requests to close WS.')
77 cherrypy.engine.publish('websocket-broadcast', TextMessage(reason))
78
79 @staticmethod
80 def SaveImage(image_data, image_file):
81 """Decoded the base64 image data and save it in the file."""
82 with open(image_file, 'w') as f:
83 f.write(base64.b64decode(image_data))
84
85
86class TouchDeviceWrapper(object):
87 """This is a wrapper of remote.RemoteTouchDevice.
88
89 It handles the instantiation of different device types, and the beginning
90 and ending of the event stream.
91 """
92
93 def __init__(self, dut_type, addr, is_touchscreen):
94 if dut_type == 'chromeos':
95 self.device = ChromeOSTouchDevice(addr, is_touchscreen)
96 else:
97 self.device = AndroidTouchDevice(addr, True)
98
99 def close(self):
100 """ Close the device gracefully. """
101 if self.device.event_stream_process:
102 self.device.__del__()
103
104 def __str__(self):
105 return '\n '.join(sorted([str(slot) for slot in self.slots.values()]))
106
107
108def ThreadedGetLiveStreamSnapshots(device, saved_file):
109 """A thread to poll and get live stream snapshots continuously."""
110
111 def _ConvertNamedtupleToDict(snapshot, prev_tids):
112 """Convert namedtuples to ordinary dictionaries and add leaving slots.
113
114 This is to make a snapshot json serializable. Otherwise, the namedtuples
115 would be transmitted as arrays which is less readable.
116
117 A snapshot looks like
118 MtSnapshot(
119 syn_time=1420524008.368854,
120 button_pressed=False,
121 fingers=[
122 MtFinger(tid=162, slot=0, syn_time=1420524008.368854, x=524,
123 y=231, pressure=45),
124 MtFinger(tid=163, slot=1, syn_time=1420524008.368854, x=677,
125 y=135, pressure=57)
126 ]
127 )
128
129 Note:
130 1. that there are two levels of namedtuples to convert.
131 2. The leaving slots are used to notify javascript that a finger is leaving
132 so that the corresponding finger color could be released for reuse.
133 """
134 # Convert MtSnapshot.
135 converted = dict(snapshot.__dict__.items())
136
137 # Convert MtFinger.
138 converted['fingers'] = [dict(finger.__dict__.items())
139 for finger in converted['fingers']]
140 converted['raw_events'] = [str(event) for event in converted['raw_events']]
141
142 # Add leaving fingers to notify js for reclaiming the finger colors.
143 curr_tids = [finger['tid'] for finger in converted['fingers']]
144 for tid in set(prev_tids) - set(curr_tids):
145 leaving_finger = {'tid': tid, 'leaving': True}
146 converted['fingers'].append(leaving_finger)
147
148 return converted, curr_tids
149
150 def _GetSnapshots():
151 """Get live stream snapshots."""
152 cherrypy.log('Start getting the live stream snapshots....')
153 prev_tids = []
154 with open(saved_file, 'w') as f:
155 while True:
156 snapshot = device.device.NextSnapshot()
157 # TODO: remove the next line when NextSnapshot returns the raw events.
158 events = []
159 if snapshot:
160 f.write('\n'.join(events) + '\n')
161 f.flush()
162 snapshot, prev_tids = _ConvertNamedtupleToDict(snapshot, prev_tids)
163 cherrypy.engine.publish('websocket-broadcast', json.dumps(snapshot))
164
165 get_snapshot_thread = threading.Thread(target=_GetSnapshots,
166 name='_GetSnapshots')
167 get_snapshot_thread.daemon = True
168 get_snapshot_thread.start()
169
170
171class ConnectionState(object):
172 """A ws connection state object for shutting down the cherrypy server.
173
174 It shuts down the cherrypy server when the count is down to 0 and is not
175 increased before the shutdown_timer expires.
176
177 Note that when a page refreshes, it closes the WS connection first and
178 then re-connects immediately. This is why we would like to wait a while
179 before actually shutting down the server.
180 """
181 TIMEOUT = 1.0
182
183 def __init__(self):
184 self.count = 0;
185 self.lock = threading.Lock()
186 self.shutdown_timer = None
187
188 def IncCount(self):
189 """Increase the connection count, and cancel the shutdown timer if exists.
190 """
191 self.lock.acquire()
192 self.count += 1;
193 cherrypy.log(' WS connection count: %d' % self.count)
194 if self.shutdown_timer:
195 self.shutdown_timer.cancel()
196 self.shutdown_timer = None
197 self.lock.release()
198
199 def DecCount(self):
200 """Decrease the connection count, and start a shutdown timer if no other
201 clients are connecting to the server.
202 """
203 self.lock.acquire()
204 self.count -= 1;
205 cherrypy.log(' WS connection count: %d' % self.count)
206 if self.count == 0:
207 self.shutdown_timer = threading.Timer(self.TIMEOUT, self.Shutdown)
208 self.shutdown_timer.start()
209 self.lock.release()
210
211 def Shutdown(self):
212 """Shutdown the cherrypy server."""
213 cherrypy.log('Shutdown timer expires. Cherrypy server for Webplot exits.')
214 cherrypy.engine.exit()
215
216
217class Root(object):
218 """A class to handle requests about docroot."""
219
220 def __init__(self, ip, port, touch_min_x, touch_max_x, touch_min_y,
221 touch_max_y, touch_min_pressure, touch_max_pressure):
222 self.ip = ip
223 self.port = port
224 self.touch_min_x = touch_min_x
225 self.touch_max_x = touch_max_x
226 self.touch_min_y = touch_min_y
227 self.touch_max_y = touch_max_y
228 self.touch_min_pressure = touch_min_pressure
229 self.touch_max_pressure = touch_max_pressure
230 self.scheme = 'ws'
231 cherrypy.log('Root address: (%s, %s)' % (ip, str(port)))
232 cherrypy.log('scheme: %s' % self.scheme)
233
234 @cherrypy.expose
235 def index(self):
236 """This is the default index.html page."""
237 websocket_dict = {
238 'websocketUrl': '%s://%s:%s/ws' % (self.scheme, self.ip, self.port),
239 'touchMinX': str(self.touch_min_x),
240 'touchMaxX': str(self.touch_max_x),
241 'touchMinY': str(self.touch_min_y),
242 'touchMaxY': str(self.touch_max_y),
243 'touchMinPressure': str(self.touch_min_pressure),
244 'touchMaxPressure': str(self.touch_max_pressure),
245 }
246 root_page = os.path.join(os.path.abspath(os.path.dirname(__file__)),
247 'webplot.html')
248 with open(root_page) as f:
249 return f.read() % websocket_dict
250
251 @cherrypy.expose
252 def ws(self):
253 """This handles the request to create a new web socket per client."""
254 cherrypy.log('A new client requesting for WS')
255 cherrypy.log('WS handler created: %s' % repr(cherrypy.request.ws_handler))
256 state.IncCount()
257
258
259def SimpleSystem(cmd):
260 """Execute a system command."""
261 ret = subprocess.call(cmd, shell=True)
262 if ret:
263 logging.warning('Command (%s) failed (ret=%s).', cmd, ret)
264 return ret
265
266
267def SimpleSystemOutput(cmd):
268 """Execute a system command and get its output."""
269 try:
270 proc = subprocess.Popen(
271 cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
272 stdout, _ = proc.communicate()
273 except Exception, e:
274 logging.warning('Command (%s) failed (%s).', cmd, e)
275 else:
276 return None if proc.returncode else stdout.strip()
277
278
279def IsDestinationPortEnabled(port):
280 """Check if the destination port is enabled in iptables.
281
282 If port 8000 is enabled, it looks like
283 ACCEPT tcp -- 0.0.0.0/0 0.0.0.0/0 ctstate NEW tcp dpt:8000
284 """
285 pattern = re.compile('ACCEPT\s+tcp.+\s+ctstate\s+NEW\s+tcp\s+dpt:%d' % port)
286 rules = SimpleSystemOutput('sudo iptables -L INPUT -n --line-number')
287 for rule in rules.splitlines():
288 if pattern.search(rule):
289 return True
290 return False
291
292
293def EnableDestinationPort(port):
294 """Enable the destination port for input traffic in iptables."""
295 if IsDestinationPortEnabled(port):
296 cherrypy.log('Port %d has been already enabled in iptables.' % port)
297 else:
298 cherrypy.log('To enable port %d in iptables.' % port)
299 cmd = ('sudo iptables -A INPUT -p tcp -m conntrack --ctstate NEW '
300 '--dport %d -j ACCEPT' % port)
301 if SimpleSystem(cmd) != 0:
302 raise Error('Failed to enable port in iptables: %d.' % port)
303
304
305def _ParseArguments():
306 """Parse the command line options."""
307 parser = argparse.ArgumentParser(description='Webplot Server')
308 parser.add_argument('-d', '--dut_addr', default='localhost',
309 help='the address of the dut')
310 parser.add_argument('-s', '--server_addr', default='localhost',
311 help='the address the webplot http server listens to')
312 parser.add_argument('-p', '--server_port', default=80, type=int,
313 help='the port the web server to listen to (default: 80)')
314 parser.add_argument('--is_touchscreen', help='the DUT is touchscreen',
315 action='store_true')
316 parser.add_argument('-t', '--dut_type', default='chromeos',
317 help='dut type: chromeos, android')
318 args = parser.parse_args()
319 return args
320
321
322def Main():
323 """The main function to launch webplot service."""
324 global state
325
326 configure_logger(level=logging.DEBUG)
327 args = _ParseArguments()
328
329 print '\n' + '-' * 70
330 cherrypy.log('dut machine type: %s' % args.dut_type)
331 cherrypy.log('dut\'s touch device: %s' %
332 ('touchscreen' if args.is_touchscreen else 'touchpad'))
333 cherrypy.log('dut address: %s' % args.dut_addr)
334 cherrypy.log('web server address: %s' % args.server_addr)
335 cherrypy.log('web server port: %s' % args.server_port)
336 cherrypy.log('touch events are saved in %s' % SAVED_FILE)
337 print '-' * 70 + '\n\n'
338
339 if args.server_port == 80:
340 url = args.server_addr
341 else:
342 url = '%s:%d' % (args.server_addr, args.server_port)
343
344 msg = 'Type "%s" in browser %s to see finger traces.\n'
345 if args.server_addr == 'localhost':
346 which_machine = 'on the webplot server machine'
347 else:
348 which_machine = 'on any machine'
349
350 print '*' * 70
351 print msg % (url, which_machine)
352 print 'Press \'q\' on the browser to quit.'
353 print '*' * 70 + '\n\n'
354
355 # Allow input traffic in iptables.
356 EnableDestinationPort(args.server_port)
357
358 # Instantiate a touch device.
359 device = TouchDeviceWrapper(args.dut_type, args.dut_addr, args.is_touchscreen)
360
361 # Start to get touch snapshots from the specified touch device.
362 ThreadedGetLiveStreamSnapshots(device, SAVED_FILE)
363
364 # Create a ws connection state object to wait for the condition to
365 # shutdown the whole process.
366 state = ConnectionState()
367
368 cherrypy.config.update({
369 'server.socket_host': args.server_addr,
370 'server.socket_port': args.server_port,
371 })
372
373 WebSocketPlugin(cherrypy.engine).subscribe()
374 cherrypy.tools.websocket = WebSocketTool()
375
376 # If the cherrypy server exits for whatever reason, close the device
377 # for required cleanup. Otherwise, there might exist local/remote
378 # zombie processes.
379 cherrypy.engine.subscribe('exit', device.close)
380
381 cherrypy.engine.signal_handler.handlers['SIGINT'] = InterruptHandler
382 cherrypy.engine.signal_handler.handlers['SIGTERM'] = InterruptHandler
383
Charlie Mooney04b41532015-04-02 12:41:37 -0700384 x_min, x_max = device.device.RangeX()
385 y_min, y_max = device.device.RangeY()
386 p_min, p_max = device.device.RangeP()
387
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700388 cherrypy.quickstart(
389 Root(args.server_addr, args.server_port,
Charlie Mooney04b41532015-04-02 12:41:37 -0700390 x_min, x_max, y_min, y_max, p_min, p_max), '',
Charlie Mooneybbc05f52015-03-24 13:36:22 -0700391 config={
392 '/': {
393 'tools.staticdir.root': os.path.abspath(os.path.dirname(__file__)),
394 'tools.staticdir.on': True,
395 'tools.staticdir.dir': '',
396 },
397 '/ws': {
398 'tools.websocket.on': True,
399 'tools.websocket.handler_cls': WebplotWSHandler,
400 },
401 }
402 )
403
404
405if __name__ == '__main__':
406 Main()