# bureau import functools import glob import inspect import io import json import logging import os.path import random import string import subprocess import tempfile import textwrap #import traceback import threading import barcode import lmdb import PIL import weasyprint from weasyprint.fonts import FontConfiguration import zmq from escpos import printer from mako.template import Template from ruamel.yaml import YAML def update_commands(cls): """ this is some internal magic to keep track of our commands """ for name, method in cls.__dict__.items(): print("name %s method %s" % (name, method)) if hasattr(method, "command"): comstr = method.command cls.commands[comstr] = method return cls def add_command(comstr, name=""): """ decorator for making a method into a command """ def decorator(func): """ the decorator itself """ @functools.wraps(func) def func_wrap(*args, **kwargs): """ this is to avoid roaching the namespace """ return func(*args, **kwargs) func_wrap.command = comstr func_wrap.name = name return func_wrap return decorator def add_api(apistr, name=""): """ decorator for making a method into a public bureau api method""" def decorator(func): """ the decorator itself """ @functools.wraps(func) def func_wrap(*args, **kwargs): """ this is to avoid roaching the namespace """ return func(*args, **kwargs) func_wrap.api = apistr func_wrap.name = name return func_wrap return decorator #def log_traceback(func): # """ this is a decorator that catches tracebacks for logging""" # def wrapper(*args): # my_bureau = args[0] # try: # func(*args) # except Exception as e: # my_bureau.log.error("CRASH TRACE: {0}".format(my_bureau.name), # exc_info=e) # raise # return wrapper class LogPrinter(logging.Handler): """ LogPrinter prints logs on a receipt printer for screenless debugging. """ def __init__(self): logging.Handler.__init__(self) def emit(self, record): prn = printer.Usb(0x416, 0x5011, in_ep=0x81, out_ep=0x03) msg = self.format(record) text = textwrap.fill(msg, width=48) text += "\r\n" * 4 prn.text(text) prn.cut() class KeyValStore(object): """ A KeyValStore is a simple wrapper for LMDB flat file storage. It's very fast and simple for large databases with small (less than 4kb) entries. If you need something larger try the filesystem. If you need more structure or indexes try sqlite. Keys and values MUST BE UNICODE STRINGS! """ def __init__(self, env, name): self.env = env self.db = env.open_db(name.encode()) def store(self, key, val): """ Store a key-val pair. Returns True on success. """ with self.env.begin(write=True, db=self.db) as txn: ret = txn.put(key.encode(), val.encode()) return ret def store_and_get_shortcode(self, val): """ Find an un-used shortcode and use it as a key to store the value given. Note, each db is limited to about a billion keys so don't go too crazy. returns a 5-char shortcode string. """ def _shortcode(): # returns a random 5-char string return ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(5)) # we only have about a billion so make sure we don't collide keys with self.env.begin(write=True, db=self.db) as txn: res = "not None" while res is not None: tmpcode = _shortcode() res = txn.get(tmpcode.encode()) txn.put(tmpcode.encode(), val.encode()) return tmpcode def get(self, key): """ Look up a value. Returns value as a unicode string or None if nonexistent. """ with self.env.begin(db=self.db) as txn: res = txn.get(key.encode()) return res.decode("utf-8") def delete(self, key): """ Delete a key-val pair. Returns True on success. """ with self.env.begin(write=True, db=self.db) as txn: ret = txn.delete(key.encode) return ret class Bureau(object): """ Bureau is a base class that implements standard methods for inter-bureau communication, IO, registration and some convenient stuff for printing. """ name = "TEST" prefix = "00" version = 0 default_config = {} def __init__(self): """ set up ZeroMQ connections and register commands""" self.commands = {} self.api = {} modpath = os.path.dirname(__file__) mypath = inspect.getfile(self.__class__) self.mdir = os.path.dirname(mypath) basepath = os.path.expanduser("~/.screenless") if not os.path.exists(basepath): os.mkdir(basepath) os.chdir(basepath) self.load_config() # setup log file if "debug" in self.config: if self.config["debug"]: log_level = logging.DEBUG else: log_level = logging.ERROR else: log_level = logging.ERROR logfile = os.path.join(basepath, self.prefix + ".log") logging.basicConfig(filename=logfile, level=log_level) self.log = logging.getLogger(self.prefix) log_printer = LogPrinter() log_format = logging.Formatter('LOG ${levelname} $name: $message', style='$') log_printer.setFormatter(log_format) self.log.addHandler(log_printer) # setup a dir to store files and data self.datadir = os.path.join(basepath, self.prefix) if not os.path.exists(self.datadir): os.mkdir(self.datadir) # set up a basic key-value store with LMDB - max 10 sub-dbs # if a bureau needs more then just import lmdb and roll your own dbfile = os.path.join(self.datadir, self.prefix + ".lmdb") self.dbenv = lmdb.open(dbfile, max_dbs=10) self.context = zmq.Context() self._recv = self.context.socket(zmq.REP) self._recv.bind("ipc://" + self.prefix + ".ipc") self.log.debug("bureau " + self.name + " waiting for messages") self.log.debug("commands: ") self.log.debug(str(self.commands)) # def _log_exception(typ, value, tb): # self.log.error("CRASH TRACE: {0}".format(str(value)), exc_info=(typ, value, tb)) # sys.__excepthook__(typ, value, tb) def open_db(self, name): """ Loads and if not yet existing, creates, an LMDB database returns a KeyValStore object """ db = KeyValStore(self.dbenv, name) return db def bc_svg(self, code, tofile=False, outfile=None): """ generate a CODE128 barcode as an SVG returns a string or can write to a tmp file or specific file given arguments """ fp = io.BytesIO() barcode.generate("CODE128", code, writer=barcode.writer.SVGWriter(), output=fp) fp.seek(0) # TODO: add file output return fp.read().decode() def bc_png(self, code, outfile=None): """ generate a code128 barcode as a png returns the full path to the file if file is not specified, uses a tmp file """ # TODO: implement this (its like 5 lines!) raise NotImplemented("png generation not implemented!") def load_config(self): """ load (or reload) config data from file """ yaml = YAML() cfgfile = self.prefix + ".yml" if not os.path.exists(cfgfile): with open(cfgfile, "w") as configfile: yaml.dump(self.default_config, configfile) with open(cfgfile) as cfp: self.config = yaml.load(cfp) def send(self, recipient, message, data=None): """ send commands or API calls to another bureau. recipient: the 2-character bureau ID message: a text based message as used in many commands data: an optional dict, used in API calls returns either empty string, text or a json object """ # clean up for sloppy offices message = message.strip() if not message.endswith("."): message += "." if data: message += json.dumps(data) sender = self.context.socket(zmq.REQ) sender.connect("ipc://" + recipient + ".ipc") sender.send_string(message) events = sender.poll(timeout=10000) if events is not 0: resp = sender.recv_string() if len(resp) == 0: return None elif resp[0] == "0": if len(resp) == 1: return None else: return json.loads(resp[1:]) else: # TODO: this may need some better error handling return resp else: self.log.warning("message" + message + " sent... timed out after 10 seconds.") return None def _publish_methods(self): """ this internal method registers all public commands and bureau API methods. Inhuman Resources module can then display menus and docs. """ # register bureau with Inhuman Resources bureau_detail = {"name": self.name, "prefix": self.prefix, "desc": self.__doc__} # slight hack to avoid messy self-reference and chicken-egg waiting if self.prefix == "IR": method = getattr(self, "add_bureau") method(bureau_detail) else: self.send("IR", "addbureau", bureau_detail) # find and store all published methods for member in dir(self): method = getattr(self, member) # ignore anything that is not a method with command or api details if not (callable(method) and (hasattr(method, "command") or hasattr(method, "api"))): continue if hasattr(method, "command"): self.commands[method.command] = method cmd_detail = {"cmdname": method.name, "prefix": self.prefix, "cmd": method.command, "desc": method.__doc__} if self.prefix == "IR": method = getattr(self, "add_cmd") method(cmd_detail) else: self.send("IR", "addcommand", cmd_detail) elif hasattr(method, "api"): self.api[method.api] = method api_detail = {"apiname": method.name, "prefix": self.prefix, "api": method.api, "desc": method.__doc__} if self.prefix == "IR": method = getattr(self, "add_api_method") method(api_detail) else: self.send("IR", "addapi", api_detail) self.log.debug("registered:") self.log.debug(str(self.commands)) self.log.debug(str(self.api)) def print_full(self, template, **kwargs): """print a full page (A4) document """ # TODO: look up the printer LPR name / allow multiple printers/non-default # run template with kwargs templfile = os.path.join(self.mdir, template) self.log.debug("printing with template: %s", templfile) templ = Template(filename=templfile, strict_undefined=True) # TODO: make paper size a config variable pdfpath = tempfile.mkstemp(".pdf")[1] self.log.debug("rendering to: " + pdfpath) fontconfig = FontConfiguration() fontsdir = os.path.join(self.mdir, "fonts") for font in glob.glob(fontsdir + ".otf"): fontconfig.add_font_face("file://" + font, weasyprint.default_url_fetcher) for font in glob.glob(fontsdir + ".ttf"): self.log.debug("adding font " + "file://" + font) fontconfig.add_font_face("file://" + font, weasyprint.default_url_fetcher) weasyprint.HTML(string=templ.render_unicode(**kwargs)).write_pdf(pdfpath, font_config=fontconfig) subprocess.call("lpr -o sides=two-sided-long-edge -o InputSlot=Upper " + pdfpath, shell=True) # TODO: make this asynchronous def print_small(self, text, cut=True): """ print on Thermal Line printer. """ # TODO: look up device and width in config prn = printer.Usb(0x416, 0x5011, in_ep=0x81, out_ep=0x03) text = textwrap.fill(text, width=48) text += "\r\n" * 2 prn.text(text + "\r\n\r\n") if cut: prn.cut() def print_small_image(self, img): """ print an image on the mini thermal printer. """ # TODO: make printer id/width configured and easy prn = printer.Usb(0x416, 0x5011, in_ep=0x81, out_ep=0x03) if type(img) is PIL.Image.Image: im = img else: im = PIL.Image.open(img) # NOTE: might be worth tring to push up brightness im = PIL.ImageOps.equalize(im) # stretch histogram for nicer dither im.thumbnail((576, 1024), PIL.Image.ANTIALIAS) # resize to fit printer prn.image(im, impl="bitImageColumn") # not using this impl crashes ?? @add_command("test") def test(self, data=None): """ Standard test command. """ # stupid test to see if modules work print(("hi! testing. " + self.name + " bureau seems to work!")) return "seems to work." def _run_io(self): """ wrapper for run_io so that we can catch threaded exceptions and log """ try: self.run_io() except Exception as err: self.log.exception("%s CRASHED with %s\n", self.name, err) raise def run_io(self): """process hardware or timed input This method can be ignored for most Bureaus. It should be overloaded for any services that need to independently generate messages as this is just a placeholder. It is run in a separate thread and should handle any input or timed events. Messages are then sent to other Bureaus via the self.send connection to the OfficeManager. Don't forget to to consier thread safety issues when accessing data! """ pass def run(self): """ wrapper running the main loop and logging all exceptions """ try: self._run() except Exception as err: self.log.exception("%s CRASHED with %s\n", self.name, err) raise def _run(self): """ main loop for processing messages This runs all relelvant event processing loops. """ # start the hardware input handler io_handler = threading.Thread(target=self._run_io) io_handler.start() # register commands and api methods self._publish_methods() poller = zmq.Poller() poller.register(self._recv, zmq.POLLIN) while True: msgs = dict(poller.poll(500)) if msgs: if msgs.get(self._recv) == zmq.POLLIN: msg = self._recv.recv_string(flags=zmq.NOBLOCK) else: continue try: self.log.debug("got message:" + msg) dot = msg.find(".") ref = msg[:dot] if (dot < len(msg) - 1) and (dot > 0): self.log.debug("msg length: %d", len(msg)) self.log.debug("dot at %d", dot) # TODO: maybe trim off the trailing "." for convenience data = msg[dot + 1:] else: data = None self.log.debug("data: " + str(data)) except IndexError as err: self.log.warning("invalid message: %s", err) continue self.log.debug("got method: " + ref) if (ref in self.commands) or (ref in self.api): # TODO: cope with data for calls with no params if ref in self.api: if data: data = json.loads(data) ret = json.dumps(self.api[ref](data)) else: ret = json.dumps(self.api[ref]()) else: if data: ret = self.commands[ref](data) else: ret = self.commands[ref]() if ret is None: ret = "" ret = "0" + ret self._recv.send_string(ret) else: self.log.warning("error! Command/API %s not found", ref) self._recv.send_unicode("Error! Command/API not found.") def main(): buro = Bureau() buro.run() if __name__ == "__main__": main()