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 import csv 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()+']') # events = csv.reader(fp,delimiter=';') 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(self, filename): self.set_header("Content-Type", "application/json") filenames = sorted([name[:-4] for name in os.listdir(self.config.storage) if name not in ['.gitignore']]) if filename not in filenames: raise Exception('Invalid filename') 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 = sorted([name[:-4] for name in os.listdir(self.config.storage) if name not in ['.gitignore']]) if filename not in filenames: raise Exception('Invalid filename') 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()