import argparse import json import logging import os import tornado.ioloop import tornado.web import tornado.websocket from urllib.parse import urlparse import uuid import coloredlogs import glob from pyaxidraw import axidraw # import module from threading import Thread from queue import Queue, Empty import threading logger = logging.getLogger("drawing") argParser = argparse.ArgumentParser(description='Start up the server to have non-Mechanical Turks draw their sketches.') argParser.add_argument( '--port', '-p', default=8888, help='The port for the server to listen' ) argParser.add_argument( '--verbose', '-v', action="store_true", ) generated_image_dir = os.path.join('www','generated') def strokes2D(strokes): # strokes to a d attribute for a path d = ""; last_stroke = None; cmd = ""; for stroke in strokes: if not last_stroke: d += f"M{stroke[0]},{stroke[1]} " cmd = 'M' else: if last_stroke[2] == 1: d += " m" cmd = 'm' elif cmd != 'l': d+=' l ' cmd = 'l' rel_stroke = [stroke[0] - last_stroke[0], stroke[1] - last_stroke[1]]; d += f"{rel_stroke[0]},{rel_stroke[1]} " last_stroke = stroke; return d; class StaticFileWithHeaderHandler(tornado.web.StaticFileHandler): def set_extra_headers(self, path): """For subclass to add extra headers to the response""" if path[-5:] == '.html': self.set_header("Access-Control-Allow-Origin", "*") if path[-4:] == '.svg': self.set_header("Content-Type", "image/svg+xml") class WebSocketHandler(tornado.websocket.WebSocketHandler): CORS_ORIGINS = ['localhost', '.mturk.com', 'here.rubenvandeven.com'] connections = set() def initialize(self, draw_q: Queue): self.draw_q = draw_q def check_origin(self, origin): parsed_origin = urlparse(origin) # parsed_origin.netloc.lower() gives localhost:3333 valid = any([parsed_origin.hostname.endswith(origin) for origin in self.CORS_ORIGINS]) return valid # the client connected def open(self, p = None): self.__class__.connections.add(self) logger.info(f"New client connected: {self.request.remote_ip}") self.strokes = [] # self.write_message("hello!") # the client sent the message def on_message(self, message): logger.debug(f"recieve: {message}") try: msg = json.loads(message) # TODO: sanitize input: min/max, limit strokes if msg['action'] == 'move': # TODO: min/max input point = [float(msg['direction'][0]),float(msg['direction'][1]), bool(msg['mouse'])] self.strokes.append(point) self.draw_q.put(point) elif msg['action'] == 'up': logger.info(f'up: {msg}') point = [msg['direction'][0],msg['direction'][1], 1] self.strokes.append(point) elif msg['action'] == 'submit': logger.info(f'up: {msg}') id = self.submit_strokes() if not id: self.write_message(json.dumps('error')) return self.write_message(json.dumps({ 'action': 'submitted', 'msg': f"Submission ok, please refer to your submission as: {id}" })) elif msg['action'] == 'down': # not used, implicit in move? pass else: # self.send({'alert': 'Unknown request: {}'.format(message)}) logger.warn('Unknown request: {}'.format(message)) except Exception as e: # self.send({'alert': 'Invalid request: {}'.format(e)}) logger.exception(e) # client disconnected def on_close(self): self.__class__.rmConnection(self) logger.info(f"Client disconnected: {self.request.remote_ip}") def submit_strokes(self): if len(self.strokes) < 1: return False d = strokes2D(self.strokes) svg = f""" """ id = uuid.uuid4().hex filename = os.path.join(generated_image_dir , id+'.svg') with open(filename, 'w') as fp: logger.info(f"Wrote {filename}") fp.write(svg) return id @classmethod def rmConnection(cls, client): if client not in cls.connections: return cls.connections.remove(client) class LatestImageHandler(tornado.web.RequestHandler): def get(self): self.set_header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') self.set_header("Content-Type", "image/svg+xml") list_of_files = glob.glob(os.path.join(generated_image_dir,'*.svg')) latest_file = max(list_of_files, key=os.path.getctime) with open(latest_file, 'r') as fp: self.write(fp.read()) def axiDrawCueListener(q: Queue, isRunning: threading.Event): ad = axidraw.AxiDraw() try: ad.interactive() ad.connect() ad.options.units = 1 # set to use centimeters instead of inches ad.options.accel = 100; ad.options.speed_penup = 100 ad.options.speed_pendown = 100 ad.options.model = 2 # A3, set to 1 for A4 ad.moveto(0,0) plotterWidth = 22 plotterHeight = 18 # 16? while isRunning.is_set(): # TODO: set timeout on .get() with catch block, so we can escape if no moves come in try: move = draw_q.get(True, 1) except Empty as e: logger.log(5, "Empty queue.") else: ad.moveto(move[0]* plotterWidth, move[1]*plotterHeight) print('handler!',move) except Exception as e: logger.exception(e) finally: logger.warning("Close Axidraw connection") ad.moveto(0,0) ad.disconnect() if __name__ == "__main__": args = argParser.parse_args() coloredlogs.install( level=logging.DEBUG if args.verbose else logging.INFO, ) formatter = logging.Formatter(fmt='%(asctime)s %(module)s:%(lineno)d %(levelname)8s | %(message)s', datefmt='%Y/%m/%d %H:%M:%S') # %I:%M:%S %p AM|PM format logFileHandler = logging.handlers.RotatingFileHandler( 'mt_server.log', maxBytes=1024*512, backupCount=5 ) logFileHandler.setFormatter(formatter) logger.addHandler( logFileHandler ) draw_q = Queue() isRunning = threading.Event() isRunning.set() thread = Thread(target = axiDrawCueListener, args = (draw_q, isRunning)) thread.start() try: application = tornado.web.Application([ (r"/ws(.*)", WebSocketHandler, {'draw_q': draw_q}), (r"/latest.svg", LatestImageHandler), # TODO: have js request the right image, based on a 'start' button. This way we can trace the history of a drawing (r"/(.*)", StaticFileWithHeaderHandler, {"path": 'www', "default_filename": 'index.html'}), ], debug=True) application.listen(args.port) tornado.ioloop.IOLoop.current().start() finally: isRunning.clear()