You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

451 lines
16 KiB
Python

# bureau
import functools
import inspect
import json
import logging
import os.path
import random
import string
import subprocess
import sys
import tempfile
import textwrap
import threading
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
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__)
#slimerjs = os.path.join(modpath, "..", "lib", "slimerjs", "slimerjs")
#renderer = os.path.join(modpath, "..", "slimerjs", "rasterize.js")
#self.slimerjs = os.path.abspath(slimerjs)
#self.html2pdf = self.slimerjs + " --headless " + \
# os.path.abspath(renderer) + " "
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)
sys.excepthook = self._log_exception
# 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 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()
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)
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, 576), 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):
"""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):
"""
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
# msg = self._recv.recv_string(flags=zmq.NOBLOCK)
#except zmq.ZMQError:
# time.sleep(0.05) # don't waste CPU
# 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:]
# data = str(data) # force to be a string
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()