moonraker/moonraker/plugins/paneldue.py

697 lines
26 KiB
Python

# PanelDue LCD display support
#
# Copyright (C) 2020 Eric Callahan <arksine.code@gmail.com>
#
# 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:/<gcode_folder>". 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)