class PrintKey(db.Model): """ Keys uniquely identify a printer and include a secret so you can print to a little printer by just embedding a secret in an URL. """ id = db.Column(db.Integer, primary_key=True) secret = db.Column(db.String, default=generate_secret, unique=True) created = db.Column(db.DateTime, default=datetime.datetime.utcnow) printer_id = db.Column(db.Integer, db.ForeignKey('printer.id')) printer = db.relationship('Printer', backref=db.backref('print_keys', lazy='dynamic')) number_of_uses = db.Column(db.Integer, default=0, nullable=False) senders = db.Column(postgresql.ARRAY(db.String), default=list, nullable=False) def record_usage(self, by_name): self.number_of_uses += 1 if by_name not in self.senders: self.senders.append(by_name) flag_modified(self, 'senders') parent_id = db.Column(db.Integer, db.ForeignKey('print_key.id')) parent = db.relationship('PrintKey', backref=db.backref('children', lazy='dynamic'), remote_side=[id]) def senders_formatted(self): return ', '.join((s or 'Anonymous') for s in self.senders) @property def url(self): if 'DEVICE_KEY_DOMAIN' in os.environ: return 'https://%s/%s' % (os.environ['DEVICE_KEY_DOMAIN'], self.secret) else: # if a DEVICE_KEY_DOMAIN is not defined, we need a request object # to get the full URL. return '%sprintkey/%s' % (request.url_root, self.secret)
class PrintKey(db.Model): """ Keys uniquely identify a printer and include a secret so you can print to a little printer by just embedding a secret in an URL. """ id = db.Column(db.Integer, primary_key=True) secret = db.Column(db.String, default=generate_secret, unique=True) created = db.Column(db.DateTime, default=datetime.datetime.utcnow) printer_id = db.Column(db.Integer, db.ForeignKey('printer.id')) printer = db.relationship('Printer', backref=db.backref('print_keys', lazy='dynamic'))
class ClaimCode(db.Model): """Printer and claim codes are joined over the hardware xor. Claim codes are meant to have a temporary life time though we're not treating them like this for now. """ id = db.Column(db.Integer, primary_key=True) created = db.Column(db.DateTime, default=datetime.datetime.utcnow) by_id = db.Column(db.ForeignKey('user.id')) by = db.relationship('User', backref=db.backref('claim_codes', lazy='dynamic')) hardware_xor = db.Column(db.Integer) claim_code = db.Column(db.String, unique=True) # Intended name of printer; used for display purposes. name = db.Column(db.String) def __repr__(self): return '<ClaimCode xor: {} code: {}>'.format(self.hardware_xor, self.claim_code)
class User(db.Model): id = db.Column(db.Integer, primary_key=True) created = db.Column(db.DateTime, default=datetime.datetime.utcnow) # username can't be unique because we may have multiple identity # providers. For now we just copy the twitter handle. username = db.Column(db.String) twitter_oauth = db.relationship( 'TwitterOAuth', uselist=False, backref=db.backref('user')) def __repr__(self): return 'User {}'.format(self.username) # Flask-login interface: def is_active(self): return True def get_id(self): return self.id def is_authenticated(self): return True def claim_printer(self, claim_code, name): """Claiming can happen before the printer "calls home" for the first time so we need to be able to deal with that.""" # TODO(tom): This method probably belongs to printer, not # user. Move at some point. claim_code = claiming.canonicalize(claim_code) hcc = hardware.ClaimCode.query.filter_by(claim_code=claim_code).first() hardware_xor, _ = claiming.process_claim_code(claim_code) if hcc is not None and hcc.by != self: raise ClaimCodeInUse( "Claim code {} already claimed by {}".format( claim_code, hcc.by)) if hcc is None: hcc = hardware.ClaimCode( by_id=self.id, hardware_xor=hardware_xor, claim_code=claim_code, name=name, ) db.session.add(hcc) else: # we already have a claim code, don't do anything. pass # Check whether we've seen this printer and if so: connect it # to claim code and make it "owned" but *only* if it does not # have an owner yet. printer_query = hardware.Printer.query.filter_by( hardware_xor=hardware_xor) printer = printer_query.first() if printer is None: return if printer.owner is not None and printer.owner != self: raise CannotChangeOwner( "Printer {} already owned by {}. Cannot claim for {}.".format( printer, printer.owner, self)) assert printer_query.count() == 1, \ "hardware xor collision: {}".format(hardware_xor) printer.used_claim_code = claim_code printer.hardware_xor = hardware_xor printer.owner_id = hcc.by_id printer.name = name db.session.add(printer) return printer def signed_up_friends(self): """ A "friend" is someone this user follows on twitter. :returns: 2-tuple of (all friends, list of people who can print on this user's printer) """ friends = self.twitter_oauth.friends if not friends: return [], [] return friends, User.query.filter( User.username.in_(x.screen_name for x in friends)) def friends_printers(self): """ :returns: List of printers this user can print on. """ # TODO(tom): Querying the full database is O(N) and will not # scale. Replace the model with a N:M relationship. sn = self.twitter_oauth.screen_name reverse_friend_ids = [ x.user_id for x in TwitterOAuth.query.all() for y in (x.friends or []) if sn == y.screen_name] # Avoid expensive sql by checking for list. if not reverse_friend_ids: return [] return hardware.Printer.query.filter( hardware.Printer.owner_id.in_(reverse_friend_ids))
class Printer(db.Model): """On reset printers generate a new, unique device address, so every reset will result in a new Printer row. Note that this model is only ever created by a printer calling home. Users create a ClaimCode row. """ id = db.Column(db.Integer, primary_key=True) created = db.Column(db.DateTime, default=datetime.datetime.utcnow) device_address = db.Column(db.String) hardware_xor = db.Column(db.Integer) # Name of printer; used for display purposes. name = db.Column(db.String) # Update the following fields after we connected (i.e. joined over # hardware xor) a claim to a printer. The fields start out as # NULL. owner_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) owner = db.relationship('User', backref=db.backref('printers', lazy='dynamic')) used_claim_code = db.Column(db.String, nullable=True, unique=True) def __repr__(self): return 'Printer {}, xor: {}, owner: {}'.format(self.device_address, self.hardware_xor, self.owner_id) @classmethod def phone_home(cls, device_address): """This gets called every time the in-memory machinery thinks it has seen a printer for the first time round, e.g. after a re-connect of the websocket. """ printer = cls.query.filter_by(device_address=device_address).first() hardware_xor = bitshuffle.hardware_xor_from_device_address( device_address) if printer is not None: return printer = cls( device_address=device_address, hardware_xor=hardware_xor, owner_id=None, used_claim_code=None, ) db.session.add(printer) db.session.commit() # Connect hardware xor and printer if there is a claim code # waiting. # # Printers always generate the same XOR. I.e. we can have more # than one claim code with the same XOR. We always pick the # newest claim code. claim_code_query = ClaimCode.query.filter_by( hardware_xor=hardware_xor).order_by(desc('created'), desc('id')) claim_code = claim_code_query.first() if claim_code is None: return printer.owner_id = claim_code.by_id printer.name = claim_code.name printer.used_claim_code = claim_code.claim_code db.session.add(printer) db.session.commit() @classmethod def get_claim_code(cls, device_address): """Find and return a claim code.""" printer = cls.query.filter_by(device_address=device_address).first() if printer is None: return None db.session.commit() return printer.used_claim_code @property def is_online(self): from sirius.protocol import protocol_loop return protocol_loop.device_is_online(self.device_address) class OfflineError(Exception): pass def print_html(self, html, from_name, face='default'): from sirius.coding import image_encoding from sirius.coding import templating pixels = image_encoding.default_pipeline( templating.default_template(html, from_name=from_name)) self.print_pixels(pixels, from_name=from_name, face=face) def print_pixels(self, pixels, from_name, face='default'): from sirius.protocol import messages from sirius.protocol import protocol_loop from sirius import stats from sirius.models import messages as model_messages hardware_message = None if face == "noface": hardware_message = messages.SetDeliveryAndPrintNoFace( device_address=self.device_address, pixels=pixels, ) else: hardware_message = messages.SetDeliveryAndPrint( device_address=self.device_address, pixels=pixels, ) # If a printer is "offline" then we won't find the printer # connected and success will be false. success, next_print_id = protocol_loop.send_message( self.device_address, hardware_message) if success: stats.inc('printer.print.ok') else: stats.inc('printer.print.offline') # Store the same message in the database. png = io.BytesIO() pixels.save(png, "PNG") model_message = model_messages.Message( print_id=next_print_id, pixels=bytearray(png.getvalue()), sender_name=from_name, target_printer=self, ) # We know immediately if the printer wasn't online. if not success: model_message.failure_message = 'Printer offline' model_message.response_timestamp = datetime.datetime.utcnow() db.session.add(model_message) if not success: raise Printer.OfflineError()
class Message(db.Model): """Messages are printed by someone on ("sender") to a "target printer". """ id = db.Column(db.Integer, primary_key=True) created = db.Column(db.DateTime, default=datetime.datetime.utcnow, index=True) pixels = db.Column(db.LargeBinary) print_id = db.Column(db.Integer, unique=True) sender_name = db.Column(db.String) target_printer_id = db.Column(db.Integer, db.ForeignKey('printer.id')) target_printer = db.relationship('Printer', backref=db.backref('messages', lazy='dynamic')) # The response is received by the protocol_loop. It's also # possible to not receive a response (e.g. printer got # disconnected) in which case we "time out" after TIMEOUT_SECONDS. response_timestamp = db.Column(db.DateTime, nullable=True) failure_message = db.Column(db.String, nullable=True) @classmethod def timeout_updates(cls, utcnow=None): """Update all messages that are timed-out with an error message. This is a destructive update. It should also be fairly cheap because of the index on created. We're doing these updates "eagerly" on every printer overview page load instead of through a cron job, but there's no reason why we couldn't do that. """ if utcnow is None: utcnow = datetime.datetime.utcnow() cutoff = utcnow - datetime.timedelta(seconds=TIMEOUT_SECONDS) cls.query.filter( cls.created <= cutoff, cls.response_timestamp == None ).update(dict( response_timestamp=utcnow, failure_message="Timed out", )) db.session.commit() def base64_pixels(self): return base64.b64encode(self.pixels) @classmethod def ack(cls, return_code, command_id, utcnow=None): if return_code != 0: # TODO map codes to error messages failure_message = 'Problem printing: {}'.format(return_code) else: failure_message = None if utcnow is None: utcnow = datetime.datetime.utcnow() message = cls.query.filter_by(print_id=command_id).first() if message is None: logger.error("Ack'ed unknown message %s.", command_id) return message.response_timestamp = utcnow message.failure_message = failure_message db.session.add(message) db.session.commit() @classmethod def get_next_command_id(cls): """Return the next command id to be used by the bridge. Command ids are used to ack messages so we need to make sure we don't have any collisions. The best way to do so is to pick the next-highest number after the ones we already used up. """ last = cls.query.order_by(desc('print_id')).first() if last is None: next_print_id = 1 else: next_print_id = last.print_id + 1 db.session.commit() return next_print_id
class Printer(db.Model): """On reset printers generate a new, unique device address, so every reset will result in a new Printer row. Note that this model is only ever created by a printer calling home. Users create a ClaimCode row. """ id = db.Column(db.Integer, primary_key=True) created = db.Column(db.DateTime, default=datetime.datetime.utcnow) device_address = db.Column(db.String) hardware_xor = db.Column(db.Integer) # Name of printer; used for display purposes. name = db.Column(db.String) # Update the following fields after we connected (i.e. joined over # hardware xor) a claim to a printer. The fields start out as # NULL. owner_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) owner = db.relationship('User', backref=db.backref('printers', lazy='dynamic')) used_claim_code = db.Column(db.String, nullable=True, unique=True) def __repr__(self): return 'Printer {}, xor: {}, owner: {}'.format(self.device_address, self.hardware_xor, self.owner_id) @classmethod def phone_home(cls, device_address): """This gets called every time the in-memory machinery thinks it has seen a printer for the first time round, e.g. after a re-connect of the websocket. """ printer = cls.query.filter_by(device_address=device_address).first() hardware_xor = bitshuffle.hardware_xor_from_device_address( device_address) if printer is not None: return printer = cls( device_address=device_address, hardware_xor=hardware_xor, owner_id=None, used_claim_code=None, ) db.session.add(printer) db.session.commit() # Connect hardware xor and printer if there is a claim code # waiting. # # Printers always generate the same XOR. I.e. we can have more # than one claim code with the same XOR. We always pick the # newest claim code. claim_code_query = ClaimCode.query.filter_by( hardware_xor=hardware_xor).order_by(desc('created'), desc('id')) claim_code = claim_code_query.first() if claim_code is None: return printer.owner_id = claim_code.by_id printer.name = claim_code.name printer.used_claim_code = claim_code.claim_code db.session.add(printer) db.session.commit() @classmethod def get_claim_code(cls, device_address): """Find and return a claim code.""" printer = cls.query.filter_by(device_address=device_address).first() if printer is None: return None db.session.commit() return printer.used_claim_code