import json import logging import os import tornado.ioloop import tornado.web import tornado.websocket from urllib.parse import urlparse import uuid import datetime import html import argparse import coloredlogs import glob class DateTimeEncoder(json.JSONEncoder): def default(self, o): if isinstance(o, datetime.datetime): return o.isoformat(timespec='milliseconds') return super().default(self, o) 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): """ Websocket from the workers """ # CORS_ORIGINS = ['localhost'] connections = set() def initialize(self, config): self.config = config self.strokes = [] self.hasWritten = False self.dimensions = [None, None] # 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) self.prefix = datetime.datetime.now().strftime('%Y-%m-%d-') self.filename = self.prefix + str(self.check_filenr()) + '-' + uuid.uuid4().hex[:6] logger.info(f"{self.filename=}") self.write_message(json.dumps({ "filename": self.filename })) def check_filenr(self): files = glob.glob(os.path.join(self.config.storage, self.prefix +'*')) return len(files) + 1 def appendEvent(self, row): # write to an appendable json format. So basically a file that should be wrapped in [] to be json-parsable with open(os.path.join(self.config.storage,self.filename +'.json_appendable'), 'a') as fp: if not self.hasWritten: #metadata to first row, but only on demand fp.write(json.dumps([datetime.datetime.now().strftime("%Y-%m-%d %T"), self.dimensions[0], self.dimensions[1]])) # writer.writerow() self.hasWritten = True fp.write(',\n') # first column is color, rest is points fp.write(json.dumps(row)) # the client sent the message def on_message(self, message): logger.info(f"recieve: {message}") try: msg = json.loads(message) if msg['event'] == 'stroke': logger.info('stroke') self.appendEvent(msg) elif msg['event'] == 'dimensions': self.dimensions = [int(msg['width']), int(msg['height'])] logger.info(f"{self.dimensions=}") elif msg['event'] == 'viewbox': logger.info('move or resize') self.appendEvent(msg) 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}") @classmethod def rmConnection(cls, client): if client not in cls.connections: return cls.connections.remove(client) @classmethod def hasConnection(cls, client): return client in cls.connections class AudioListingHandler(tornado.web.RequestHandler): def initialize(self, config): self.config = config self.audiodir = os.path.join(self.config.storage, 'audio') def get(self): # filename = self.get_argument("file", None) self.set_header("Content-Type", "application/json") if not os.path.exists(self.audiodir): names = [] else: names = sorted([f"/audio/{name}" for name in os.listdir(self.audiodir) if name not in ['.gitignore']]) print(names) self.write(json.dumps(names)) class AnimationHandler(tornado.web.RequestHandler): def initialize(self, config): self.config = config def get(self, filename): self.set_header("Content-Type", "application/json") # filename = self.get_argument("file", None) if filename == '': files = [] names = [name for name in os.listdir(self.config.storage) if name.endswith('json_appendable')] for name in names: with open(os.path.join(self.config.storage, name), 'r') as fp: first_line = fp.readline().strip() if first_line.endswith(','): first_line = first_line[:-1] print(first_line) metadata = json.loads(first_line) files.append({ 'name': f"/files/{name[:-16]}", "time": metadata[0], "dimensions": [metadata[1], metadata[2]], }) files.sort(key=lambda k: k['time']) self.write(json.dumps(files)) else: path = os.path.join(self.config.storage,os.path.basename(filename)+".json_appendable") drawing = { "file": filename, "shape": [] } with open(path, 'r') as fp: events = json.loads('['+fp.read()+']') for i, event in enumerate(events): if i == 0: # metadata on first line drawing['time'] = event[0] drawing['dimensions'] = [event[1], event[2]] else: if event['event'] == 'viewbox': pass if event['event'] == 'stroke': # points = [] # for i in range(int(len(stroke) / 4)): # p = stroke[i*4:i*4+4] # points.append([float(p[0]), float(p[1]), int(p[2]), float(p[3])]) drawing['shape'].append({ 'color': event['color'], 'points': event['points'] }) self.write(json.dumps(drawing)) 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 AnnotationsHandler(tornado.web.RequestHandler): def initialize(self, config): self.config = config self.metadir = os.path.join(self.config.storage, 'metadata') def prepare(self): if self.request.headers.get("Content-Type", "").startswith("application/json"): self.json_args = json.loads(self.request.body) else: self.json_args = None def get_filenames(self): return [name[:-16] for name in os.listdir(self.config.storage) if name.endswith('json_appendable')] def get(self, filename): self.set_header("Content-Type", "application/json") filenames = self.get_filenames() print(filenames, filename) if filename not in filenames: raise tornado.web.HTTPError(404) meta_file = os.path.join(self.metadir, filename +'.json') if not os.path.exists(meta_file): self.set_status(404) return with open(meta_file, 'r') as fp: self.write(json.load(fp)) def post(self, filename): # filename = self.get_argument("file", None) filenames = self.get_filenames() print(filenames, filename) if filename not in filenames: raise tornado.web.HTTPError(404) if not os.path.exists(self.metadir): os.mkdir(self.metadir) meta_file = os.path.join(self.metadir, filename +'.json') with open(meta_file, 'w') as fp: json.dump(self.json_args, fp) class Server: """ Server for HIT -> plotter events As well as for the Status interface """ loop = None def __init__(self, config, logger): self.config = config self.logger = logger #self.config['server']['port'] self.web_root = os.path.join('www') def start(self): application = tornado.web.Application([ (r"/ws(.*)", WebSocketHandler, { 'config': self.config, }), (r"/files/(.*)", AnimationHandler, {'config': self.config}), (r"/audio/(.+)", tornado.web.StaticFileHandler, {"path": os.path.join(self.config.storage, 'audio')}), (r"/audio", AudioListingHandler, {'config': self.config}), (r"/annotations/(.+)", AnnotationsHandler, {'config': self.config}), (r"/(.*)", StaticFileWithHeaderHandler, {"path": self.web_root}), ], debug=True, autoreload=True) application.listen(self.config.port) tornado.ioloop.IOLoop.current().start() if __name__ == "__main__": argParser = argparse.ArgumentParser( description='Start up the vector animation server') # argParser.add_argument( # '--config', # '-c', # required=True, # type=str, # help='The yaml config file to load' # ) argParser.add_argument( '--port', type=int, default=7890, help='Port' ) argParser.add_argument( '--storage', type=str, default='files', help='directory name for output files' ) argParser.add_argument( '--verbose', '-v', action='count', default=0 ) args = argParser.parse_args() loglevel = logging.NOTSET if args.verbose > 1 else logging.DEBUG if args.verbose > 0 else logging.INFO coloredlogs.install( level=loglevel, # default: "%(asctime)s %(hostname)s %(name)s[%(process)d] %(levelname)s %(message)s" fmt="%(asctime)s %(hostname)s %(name)s[%(process)d,%(threadName)s] %(levelname)s %(message)s" ) # File logging 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( 'log/draw_log.log', maxBytes=1024*512, backupCount=5 ) logFileHandler.setFormatter(formatter) logger = logging.getLogger("sorteerhoed") logger.addHandler( logFileHandler ) logger.info(f"Start server: http://localhost:{args.port}") server = Server(args, logger) server.start()