# PanelDue LCD display support # # Copyright (C) 2020 Eric Callahan # # This file may be distributed under the terms of the GNU GPLv3 license. import serial import os import time import json import errno import logging import tempfile from utils import ServerError from tornado import gen, netutil from tornado.ioloop import IOLoop from tornado.locks import Lock MIN_EST_TIME = 10. class PanelDueError(ServerError): pass class SerialConnection: def __init__(self, paneldue): self.ioloop = IOLoop.current() self.paneldue = paneldue self.port = "" self.baud = 57600 self.sendlock = Lock() self.partial_input = b"" self.ser = self.fd = None self.connected = False def load_config(self, config): port = config.get('serial', None) baud = int(config.get('baud', 57600)) if port is None: logging.info("No serial port specified, cannot connect") return if port != self.port or baud != self.baud or \ not self.connected: self.disconnect() self.port = port self.baud = baud self.ioloop.spawn_callback(self._connect) def disconnect(self): if self.connected: if self.fd is not None: self.ioloop.remove_handler(self.fd) self.fd = None self.connected = False self.ser.close() self.ser = None logging.info("PanelDue Disconnected") async def _connect(self): start_time = connect_time = time.time() while not self.connected: if connect_time > start_time + 30.: logging.info("Unable to connect, aborting") break logging.info("Attempting to connect to: %s" % (self.port)) try: # XXX - sometimes the port cannot be exclusively locked, this # would likely be due to a restart where the serial port was # not correctly closed. Maybe don't use exclusive mode? self.ser = serial.Serial( self.port, self.baud, timeout=0, exclusive=True) except (OSError, IOError, serial.SerialException): logging.exception("Unable to open port: %s" % (self.port)) await gen.sleep(2.) connect_time += time.time() continue self.fd = self.ser.fileno() os.set_blocking(self.fd, False) self.ioloop.add_handler( self.fd, self._handle_incoming, IOLoop.READ | IOLoop.ERROR) self.connected = True logging.info("PanelDue Connected") def _handle_incoming(self, fd, events): if events & IOLoop.ERROR: logging.info("PanelDue Connection Error") self.disconnect() return # Process incoming data using same method as gcode.py try: data = os.read(fd, 4096) except os.error: return if not data: # possibly an error, disconnect self.disconnect() logging.info("serial_display: No data received, disconnecting") return self.ioloop.spawn_callback(self._process_data, data) async def _process_data(self, data): # Remove null bytes, separate into lines data = data.strip(b'\x00') lines = data.split(b'\n') lines[0] = self.partial_input + lines[0] self.partial_input = lines.pop() for line in lines: line = line.strip().decode() try: await self.paneldue.process_line(line) except ServerError: logging.exception( "GCode Processing Error: " + line) self.paneldue.handle_gcode_response( "!! GCode Processing Error: " + line) except Exception: logging.exception("Error during gcode processing") async def send(self, data): if self.connected: async with self.sendlock: while data: try: sent = os.write(self.fd, data) except os.error as e: if e.errno == errno.EBADF or e.errno == errno.EPIPE: sent = 0 else: await gen.sleep(.001) continue if sent: data = data[sent:] else: logging.exception( "Error writing data, closing serial connection") self.disconnect() return class PanelDue: def __init__(self, server): self.server = server self.ioloop = IOLoop.current() self.ser_conn = SerialConnection(self) self.file_manager = self.server.load_plugin('file_manager') self.kinematics = "none" self.machine_name = "Klipper" self.firmware_name = "Repetier | Klipper" self.last_message = None self.last_gcode_response = None self.current_file = "" self.file_metadata = {} # Initialize tracked state. self.printer_state = { 'gcode': {}, 'toolhead': {}, 'virtual_sdcard': {}, 'fan': {}, 'display_status': {}, 'print_stats': {}} self.available_macros = {} self.non_trivial_keys = [] self.extruder_count = 0 self.heaters = [] self.is_ready = False self.is_shutdown = False self.last_printer_state = 'C' # Register server events self.server.register_event_handler( "server:klippy_state_changed", self.handle_klippy_state) self.server.register_event_handler( "server:status_update", self.handle_status_update) self.server.register_event_handler( "server:gcode_response", self.handle_gcode_response) self.server.register_remote_method( "paneldue_beep", self.handle_paneldue_beep) # These commands are directly executued on the server and do not to # make a request to Klippy self.direct_gcodes = { 'M20': self._run_paneldue_M20, 'M30': self._run_paneldue_M30, 'M36': self._run_paneldue_M36, 'M408': self._run_paneldue_M408 } # These gcodes require special parsing or handling prior to being # sent via Klippy's "gcode/script" api command. self.special_gcodes = { 'M0': lambda args: "CANCEL_PRINT", 'M23': self._prepare_M23, 'M24': lambda args: "RESUME", 'M25': lambda args: "PAUSE", 'M32': self._prepare_M32, 'M98': self._prepare_M98, 'M120': lambda args: "SAVE_GCODE_STATE STATE=PANELDUE", 'M121': lambda args: "RESTORE_GCODE_STATE STATE=PANELDUE", 'M290': self._prepare_M290, 'M999': lambda args: "FIRMWARE_RESTART" } def load_config(self, config): self.ser_conn.load_config(config) self.machine_name = config.get('machine_name', self.machine_name) macros = config.get('macros', None) if macros is not None: # The macro's configuration name is the key, whereas the full # command is the value macros = [m for m in macros.split('\n') if m.strip()] self.available_macros = {m.split()[0]: m for m in macros} else: self.available_macros = {} ntkeys = config.get('non_trivial_keys', "Klipper state") self.non_trivial_keys = [k for k in ntkeys.split('\n') if k.strip()] self.ioloop.spawn_callback(self.write_response, {'status': 'C'}) logging.info("PanelDue Configured") async def _klippy_request(self, command, method='GET', args={}): request = self.server.make_request(command, method, args) result = await request.wait() if isinstance(result, self.server.error): raise PanelDueError(str(result)) return result async def handle_klippy_state(self, state): if state == "ready": await self._process_klippy_ready() elif state == "shutdown": await self._process_klippy_shutdown() elif state == "disconnect": await self._process_klippy_disconnect() async def _process_klippy_ready(self): # Request "info" and "configfile" status retries = 10 printer_info = cfg_status = {} while retries: try: printer_info = await self._klippy_request("info") cfg_status = await self._klippy_request( "objects/status", args={'configfile': []}) except PanelDueError: logging.exception("PanelDue initialization request failed") retries -= 1 if not retries: raise await gen.sleep(1.) continue break self.firmware_name = "Repetier | Klipper " + printer_info['version'] config = cfg_status.get('configfile', {}).get('config', {}) printer_cfg = config.get('printer', {}) self.kinematics = printer_cfg.get('kinematics', "none") logging.info( "PanelDue Config Received:\n" "Firmware Name: %s\n" "Kinematics: %s\n" "Printer Config: %s\n" % (self.firmware_name, self.kinematics, str(config))) # Initalize printer state and make subscription request self.printer_state = { 'gcode': {}, 'toolhead': {}, 'virtual_sdcard': {}, 'fan': {}, 'display_status': {}, 'print_stats': {}} sub_args = {k: [] for k in self.printer_state.keys()} self.extruder_count = 0 self.heaters = [] for cfg in config: if cfg.startswith("extruder"): self.extruder_count += 1 self.printer_state[cfg] = {} self.heaters.append(cfg) sub_args[cfg] = [] elif cfg == "heater_bed": self.printer_state[cfg] = {} self.heaters.append(cfg) sub_args[cfg] = [] try: await self._klippy_request( "objects/subscription", method='POST', args=sub_args) except PanelDueError: logging.exception("Unable to complete subscription request") self.is_shutdown = False self.is_ready = True async def _process_klippy_shutdown(self): self.is_shutdown = True async def _process_klippy_disconnect(self): # Tell the PD that we are shutting down await self.write_response({'status': 'S'}) self.is_ready = False async def handle_status_update(self, status): self.printer_state.update(status) def handle_paneldue_beep(self, frequency, duration): duration = int(duration * 1000.) self.ioloop.spawn_callback( self.write_response, {'beep_freq': frequency, 'beep_length': duration}) async def process_line(self, line): # If we find M112 in the line then skip verification if "M112" in line.upper(): await self._klippy_request("emergency_stop", method='POST') return # Get line number line_index = line.find(' ') try: line_no = int(line[1:line_index]) except Exception: line_index = -1 line_no = None # Verify checksum cs_index = line.rfind('*') try: checksum = int(line[cs_index+1:]) except Exception: # Invalid checksum, do not process msg = "!! Invalid Checksum" if line_no is not None: msg = " Line Number: %d" % line_no logging.exception("PanelDue: " + msg) raise PanelDueError(msg) # Checksum is calculated by XORing every byte in the line other # than the checksum itself calculated_cs = 0 for c in line[:cs_index]: calculated_cs ^= ord(c) if calculated_cs & 0xFF != checksum: msg = "!! Invalid Checksum" if line_no is not None: msg = " Line Number: %d" % line_no logging.info("PanelDue: " + msg) raise PanelDueError(msg) await self._run_gcode(line[line_index+1:cs_index]) async def _run_gcode(self, script): # Execute the gcode. Check for special RRF gcodes that # require special handling parts = script.split() cmd = parts[0].strip() # Check for commands that query state and require immediate response if cmd in self.direct_gcodes: params = {} for p in parts[1:]: arg = p[0].lower() if p[0].lower() in "psr" else "p" try: val = int(p[1:].strip()) if arg in "sr" else p[1:].strip() except Exception: msg = "paneldue: Error parsing direct gcode %s" % (script) self.handle_gcode_response("!! " + msg) logging.exception(msg) return params["arg_" + arg] = val func = self.direct_gcodes[cmd] await func(**params) return # Prepare GCodes that require special handling if cmd in self.special_gcodes: func = self.special_gcodes[cmd] script = func(parts[1:]) try: args = {'script': script} await self._klippy_request( "gcode/script", method='POST', args=args) except PanelDueError: msg = "Error executing script %s" % script self.handle_gcode_response("!! " + msg) logging.exception(msg) def _clean_filename(self, filename): # Remove drive number if filename.startswith("0:/"): filename = filename[3:] # Remove initial "gcodes" folder. This is necessary # due to the HACK in the paneldue_M20 gcode. if filename.startswith("gcodes/"): filename = filename[6:] elif filename.startswith("/gcodes/"): filename = filename[7:] # Start with a "/" so the gcode parser can correctly # handle files that begin with digits or special chars if filename[0] != "/": filename = "/" + filename return filename def _prepare_M23(self, args): filename = self._clean_filename(args[0].strip()) return "M23 " + filename def _prepare_M32(self, args): filename = self._clean_filename(args[0].strip()) return "SDCARD_PRINT_FILE FILENAME=" + filename def _prepare_M98(self, args): macro = args[0][1:].strip() name_start = macro.rfind('/') + 1 macro = macro[name_start:] cmd = self.available_macros.get(macro) if cmd is None: raise PanelDueError("Macro %s invalid" % (macro)) return cmd def _prepare_M290(self, args): # args should in in the format Z0.02 offset = args[0][1:].strip() return "SET_GCODE_OFFSET Z_ADJUST=%s MOVE=1" % (offset) def handle_gcode_response(self, response): # Only queue up "non-trivial" gcode responses. At the # moment we'll handle state changes and errors if "Klipper state" in response \ or response.startswith('!!'): self.last_gcode_response = response else: for key in self.non_trivial_keys: if key in response: self.last_gcode_response = response return async def write_response(self, response): byte_resp = json.dumps(response) + "\r\n" await self.ser_conn.send(byte_resp.encode()) def _get_printer_status(self): # PanelDue States applicable to Klipper: # I = idle, P = printing from SD, S = stopped (shutdown), # C = starting up (not ready), A = paused, D = pausing, # B = busy if self.is_shutdown: return 'S' printer_state = self.printer_state th_busy = printer_state['toolhead'].get( 'status', 'Ready') == "Printing" sd_state = printer_state['print_stats'].get('state', "standby") if sd_state == "printing": if self.last_printer_state == 'A': # Resuming return 'R' # Printing return 'P' elif sd_state == "paused": if th_busy and self.last_printer_state != 'A': # Pausing return 'D' else: # Paused return 'A' if th_busy: # Printer is "busy" return 'B' return 'I' async def _run_paneldue_M408(self, arg_r=None, arg_s=1): response = {} sequence = arg_r response_type = arg_s if not self.is_ready: # Klipper is still starting up, do not query status self.last_printer_state = 'S' if self.is_shutdown else 'C' response['status'] = self.last_printer_state await self.write_response(response) return # Send gcode responses if sequence is not None and self.last_gcode_response: response['seq'] = sequence + 1 response['resp'] = self.last_gcode_response self.last_gcode_response = None if response_type == 1: # Extended response Request response['myName'] = self.machine_name response['firmwareName'] = self.firmware_name response['numTools'] = self.extruder_count response['geometry'] = self.kinematics response['axes'] = 3 p_state = self.printer_state self.last_printer_state = self._get_printer_status() response['status'] = self.last_printer_state response['babystep'] = round(p_state['gcode'].get( 'homing_zpos', 0.), 3) # Current position pos = p_state['toolhead'].get('position', [0., 0., 0., 0.]) response['pos'] = [round(p, 2) for p in pos[:3]] homed_pos = p_state['toolhead'].get('homed_axes', "") response['homed'] = [int(a in homed_pos) for a in "xyz"] sfactor = round(p_state['gcode'].get('speed_factor', 1.) * 100, 2) response['sfactor'] = sfactor # Print Progress Tracking sd_status = p_state['virtual_sdcard'] print_stats = p_state['print_stats'] fname = print_stats.get('filename', "") sd_print_state = print_stats.get('state') if sd_print_state in ['printing', 'paused']: # We know a file has been loaded, initialize metadata if self.current_file != fname: self.current_file = fname self.file_metadata = self.file_manager.get_file_metadata(fname) progress = sd_status.get('progress', 0) # progress and print tracking if progress: response['fraction_printed'] = round(progress, 3) est_time = self.file_metadata.get('estimated_time', 0) if est_time > MIN_EST_TIME: # file read estimate times_left = [int(est_time - est_time * progress)] # filament estimate est_total_fil = self.file_metadata.get('filament_total') if est_total_fil: cur_filament = print_stats.get('filament_used', 0.) fpct = min(1., cur_filament / est_total_fil) times_left.append(int(est_time - est_time * fpct)) # object height estimate obj_height = self.file_metadata.get('object_height') if obj_height: cur_height = p_state['gcode'].get('move_zpos', 0.) hpct = min(1., cur_height / obj_height) times_left.append(int(est_time - est_time * hpct)) else: # The estimated time is not in the metadata, however we # can still provide an estimate based on file progress duration = print_stats.get('print_duration', 0.) times_left = [int(duration / progress - duration)] response['timesLeft'] = times_left else: # clear filename and metadata self.current_file = "" self.file_metadata = {} fan_speed = p_state['fan'].get('speed') if fan_speed is not None: response['fanPercent'] = [round(fan_speed * 100, 1)] if self.extruder_count > 0: extruder_name = p_state['toolhead'].get('extruder') if extruder_name is not None: tool = 0 if extruder_name != "extruder": tool = int(extruder_name[-1]) response['tool'] = tool # Report Heater Status efactor = round(p_state['gcode'].get('extrude_factor', 1.) * 100., 2) for name in self.heaters: temp = round(p_state[name].get('temperature', 0.0), 1) target = round(p_state[name].get('target', 0.0), 1) response.setdefault('heaters', []).append(temp) response.setdefault('active', []).append(target) response.setdefault('standby', []).append(target) response.setdefault('hstat', []).append(2 if target else 0) if name.startswith('extruder'): response.setdefault('efactor', []).append(efactor) response.setdefault('extr', []).append(round(pos[3], 2)) # Display message (via M117) msg = p_state['display_status'].get('message') if msg and msg != self.last_message: response['message'] = msg # reset the message so it only shows once. The paneldue # is strange about this, and displays it as a full screen # notification self.last_message = msg await self.write_response(response) async def _run_paneldue_M20(self, arg_p, arg_s=0): response_type = arg_s if response_type != 2: logging.info( "PanelDue: Cannot process response type %d in M20" % (response_type)) return path = arg_p # Strip quotes if they exist path = path.strip('\"') # Path should come in as "0:/macros, or 0:/". With # repetier compatibility enabled, the default folder is root, # ie. "0:/" if path.startswith("0:/"): path = path[2:] response = {'dir': path} response['files'] = [] if path == "/macros": response['files'] = list(self.available_macros.keys()) else: # HACK: The PanelDue has a bug where it does not correctly detect # subdirectories if we return the root as "/". Moonraker can # support a "gcodes" directory, however we must choose between this # support or disabling RRF specific gcodes (this is done by # identifying as Repetier). # The workaround below converts both "/" and "/gcodes" paths to # "gcodes". if path == "/": response['dir'] = "/gcodes" path = "gcodes" elif path.startswith("/gcodes"): path = path[1:] flist = self.file_manager.list_dir(path, simple_format=True) if flist: response['files'] = flist await self.write_response(response) async def _run_paneldue_M30(self, arg_p=None): # Delete a file. Clean up the file name and make sure # it is relative to the "gcodes" root. path = arg_p path = path.strip('\"') if path.startswith("0:/"): path = path[3:] elif path[0] == "/": path = path[1:] if not path.startswith("gcodes/"): path = "gcodes/" + path self.file_manager.delete_file(path) async def _run_paneldue_M36(self, arg_p=None): response = {} filename = arg_p sd_status = self.printer_state.get('virtual_sdcard', {}) print_stats = self.printer_state.get('print_stats', {}) if filename is None: # PanelDue is requesting file information on a # currently printed file active = False if sd_status and print_stats: filename = print_stats['filename'] active = sd_status['is_active'] if not filename or not active: # Either no file printing or no virtual_sdcard response['err'] = 1 await self.write_response(response) return else: response['fileName'] = filename.split("/")[-1] # For consistency make sure that the filename begins with the # "gcodes/" root. The M20 HACK should add this in some cases. # Ideally we would add support to the PanelDue firmware that # indicates Moonraker supports a "gcodes" directory. if not filename.startswith("gcodes/"): filename = "gcodes/" + filename metadata = self.file_manager.get_file_metadata(filename) if metadata: response['err'] = 0 response['size'] = metadata['size'] # workaround for PanelDue replacing the first "T" found response['lastModified'] = "T" + metadata['modified'] slicer = metadata.get('slicer') if slicer is not None: response['generatedBy'] = slicer height = metadata.get('object_height') if height is not None: response['height'] = round(height, 2) layer_height = metadata.get('layer_height') if layer_height is not None: response['layerHeight'] = round(layer_height, 2) filament = metadata.get('filament_total') if filament is not None: response['filament'] = [round(filament, 1)] est_time = metadata.get('estimated_time') if est_time is not None: response['printTime'] = int(est_time + .5) else: response['err'] = 1 await self.write_response(response) async def close(self): self.ser_conn.disconnect() def load_plugin(server): return PanelDue(server)