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