paneldue: add annotations

Signed-off-by:  Eric Callahan <arksine.code@gmail.com>
This commit is contained in:
Arksine 2021-05-15 11:08:52 -04:00
parent 1ada457364
commit 1026d59cad
1 changed files with 154 additions and 109 deletions

View File

@ -3,18 +3,39 @@
# Copyright (C) 2020 Eric Callahan <arksine.code@gmail.com>
#
# This file may be distributed under the terms of the GNU GPLv3 license.
from __future__ import annotations
import serial
import os
import time
import json
import errno
import logging
import asyncio
from collections import deque
from utils import ServerError
from tornado import gen
from tornado.ioloop import IOLoop
# Annotation imports
from typing import (
TYPE_CHECKING,
Deque,
Any,
Tuple,
Optional,
Dict,
List,
Callable,
Coroutine,
)
if TYPE_CHECKING:
from confighelper import ConfigHelper
from . import klippy_apis
from . import file_manager
APIComp = klippy_apis.KlippyAPI
FMComp = file_manager.FileManager
FlexCallback = Callable[..., Optional[Coroutine]]
MIN_EST_TIME = 10.
INITIALIZE_TIMEOUT = 10.
@ -25,26 +46,31 @@ class PanelDueError(ServerError):
RESTART_GCODES = ["RESTART", "FIRMWARE_RESTART"]
class SerialConnection:
def __init__(self, config, paneldue):
def __init__(self,
config: ConfigHelper,
paneldue: PanelDue
) -> None:
self.ioloop = IOLoop.current()
self.paneldue = paneldue
self.port = config.get('serial')
self.port: str = config.get('serial')
self.baud = config.getint('baud', 57600)
self.partial_input = b""
self.ser = self.fd = None
self.connected = False
self.send_busy = False
self.send_buffer = b""
self.attempting_connect = True
self.partial_input: bytes = b""
self.ser: Optional[serial.Serial] = None
self.fd: Optional[int] = None
self.connected: bool = False
self.send_busy: bool = False
self.send_buffer: bytes = b""
self.attempting_connect: bool = True
self.ioloop.spawn_callback(self._connect)
def disconnect(self, reconnect=False):
def disconnect(self, reconnect: bool = False) -> None:
if self.connected:
if self.fd is not None:
self.ioloop.remove_handler(self.fd)
self.fd = None
self.connected = False
self.ser.close()
if self.ser is not None:
self.ser.close()
self.ser = None
self.partial_input = b""
self.send_buffer = b""
@ -52,9 +78,9 @@ class SerialConnection:
logging.info("PanelDue Disconnected")
if reconnect and not self.attempting_connect:
self.attempting_connect = True
self.ioloop.call_later(1., self._connect)
self.ioloop.call_later(1., self._connect) # type: ignore
async def _connect(self):
async def _connect(self) -> None:
start_time = connect_time = time.time()
while not self.connected:
if connect_time > start_time + 30.:
@ -73,14 +99,15 @@ class SerialConnection:
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)
fd = self.fd = self.ser.fileno()
os.set_blocking(fd, False)
self.ioloop.add_handler(fd, self._handle_incoming,
IOLoop.READ | IOLoop.ERROR)
self.connected = True
logging.info("PanelDue Connected")
self.attempting_connect = False
def _handle_incoming(self, fd, events):
def _handle_incoming(self, fd: int, events: int) -> None:
if events & IOLoop.ERROR:
logging.info("PanelDue Connection Error")
self.disconnect(reconnect=True)
@ -104,23 +131,24 @@ class SerialConnection:
self.partial_input = lines.pop()
for line in lines:
try:
line = line.strip().decode('utf-8', 'ignore')
self.paneldue.process_line(line)
decoded_line = line.strip().decode('utf-8', 'ignore')
self.paneldue.process_line(decoded_line)
except ServerError:
logging.exception(
f"GCode Processing Error: {line}")
f"GCode Processing Error: {decoded_line}")
self.paneldue.handle_gcode_response(
f"!! GCode Processing Error: {line}")
f"!! GCode Processing Error: {decoded_line}")
except Exception:
logging.exception("Error during gcode processing")
def send(self, data):
def send(self, data: bytes) -> None:
self.send_buffer += data
if not self.send_busy:
self.send_busy = True
self.ioloop.spawn_callback(self._do_send)
async def _do_send(self):
async def _do_send(self) -> None:
assert self.fd is not None
while self.send_buffer:
if not self.connected:
break
@ -142,41 +170,44 @@ class SerialConnection:
self.send_busy = False
class PanelDue:
def __init__(self, config):
def __init__(self, config: ConfigHelper) -> None:
self.server = config.get_server()
self.ioloop = IOLoop.current()
self.file_manager = self.server.lookup_component('file_manager')
self.klippy_apis = self.server.lookup_component('klippy_apis')
self.kinematics = "none"
self.file_manager: FMComp = \
self.server.lookup_component('file_manager')
self.klippy_apis: APIComp = \
self.server.lookup_component('klippy_apis')
self.kinematics: str = "none"
self.machine_name = config.get('machine_name', "Klipper")
self.firmware_name = "Repetier | Klipper"
self.last_message = None
self.last_gcode_response = None
self.current_file = ""
self.file_metadata = {}
self.firmware_name: str = "Repetier | Klipper"
self.last_message: Optional[str] = None
self.last_gcode_response: Optional[str] = None
self.current_file: str = ""
self.file_metadata: Dict[str, Any] = {}
self.enable_checksum = config.getboolean('enable_checksum', True)
self.debug_queue = deque(maxlen=100)
self.debug_queue: Deque[str] = deque(maxlen=100)
# Initialize tracked state.
self.printer_state = {
self.printer_state: Dict[str, Dict[str, Any]] = {
'gcode_move': {}, 'toolhead': {}, 'virtual_sdcard': {},
'fan': {}, 'display_status': {}, 'print_stats': {},
'idle_timeout': {}, 'gcode_macro PANELDUE_BEEP': {}}
self.extruder_count = 0
self.heaters = []
self.is_ready = False
self.is_shutdown = False
self.initialized = False
self.cq_busy = self.gq_busy = False
self.command_queue = []
self.gc_queue = []
self.last_printer_state = 'O'
self.last_update_time = 0.
self.extruder_count: int = 0
self.heaters: List[str] = []
self.is_ready: bool = False
self.is_shutdown: bool = False
self.initialized: bool = False
self.cq_busy: bool = False
self.gq_busy: bool = False
self.command_queue: List[Tuple[FlexCallback, Any, Any]] = []
self.gc_queue: List[str] = []
self.last_printer_state: str = 'O'
self.last_update_time: float = 0.
# Set up macros
self.confirmed_gcode = ""
self.mbox_sequence = 0
self.available_macros = {}
self.confirmed_gcode: str = ""
self.mbox_sequence: int = 0
self.available_macros: Dict[str, str] = {}
self.confirmed_macros = {
"RESTART": "RESTART",
"FIRMWARE_RESTART": "FIRMWARE_RESTART"}
@ -184,14 +215,14 @@ class PanelDue:
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}
macro_list = [m for m in macros.split('\n') if m.strip()]
self.available_macros = {m.split()[0]: m for m in macro_list}
conf_macros = config.get('confirmed_macros', None)
if conf_macros is not None:
# The macro's configuration name is the key, whereas the full
# command is the value
conf_macros = [m for m in conf_macros.split('\n') if m.strip()]
self.confirmed_macros = {m.split()[0]: m for m in conf_macros}
macro_list = [m for m in conf_macros.split('\n') if m.strip()]
self.confirmed_macros = {m.split()[0]: m for m in macro_list}
self.available_macros.update(self.confirmed_macros)
ntkeys = config.get('non_trivial_keys', "Klipper state")
@ -216,7 +247,7 @@ class PanelDue:
# These commands are directly executued on the server and do not to
# make a request to Klippy
self.direct_gcodes = {
self.direct_gcodes: Dict[str, FlexCallback] = {
'M20': self._run_paneldue_M20,
'M30': self._run_paneldue_M30,
'M36': self._run_paneldue_M36,
@ -225,7 +256,7 @@ class PanelDue:
# These gcodes require special parsing or handling prior to being
# sent via Klippy's "gcode/script" api command.
self.special_gcodes = {
self.special_gcodes: Dict[str, Callable[[List[str]], str]] = {
'M0': lambda args: "CANCEL_PRINT",
'M23': self._prepare_M23,
'M24': lambda args: "RESUME",
@ -239,7 +270,7 @@ class PanelDue:
'M999': lambda args: "FIRMWARE_RESTART"
}
async def _process_klippy_ready(self):
async def _process_klippy_ready(self) -> None:
# Request "info" and "configfile" status
retries = 10
printer_info = cfg_status = {}
@ -259,8 +290,9 @@ class PanelDue:
self.firmware_name = "Repetier | Klipper " + \
printer_info['software_version']
config = cfg_status.get('configfile', {}).get('config', {})
printer_cfg = config.get('printer', {})
config: Dict[str, Any] = cfg_status.get(
'configfile', {}).get('config', {})
printer_cfg: Dict[str, Any] = config.get('printer', {})
self.kinematics = printer_cfg.get('kinematics', "none")
logging.info(
@ -288,6 +320,7 @@ class PanelDue:
self.heaters.append(cfg)
sub_args[cfg] = None
try:
status: Dict[str, Any]
status = await self.klippy_apis.subscribe_objects(sub_args)
except self.server.error:
logging.exception("Unable to complete subscription request")
@ -296,28 +329,28 @@ class PanelDue:
self.is_shutdown = False
self.is_ready = True
def _process_klippy_shutdown(self):
def _process_klippy_shutdown(self) -> None:
self.is_shutdown = True
def _process_klippy_disconnect(self):
def _process_klippy_disconnect(self) -> None:
# Tell the PD that the printer is "off"
self.write_response({'status': 'O'})
self.last_printer_state = 'O'
self.is_shutdown = self.is_shutdown = False
def handle_status_update(self, status):
def handle_status_update(self, status: Dict[str, Any]) -> None:
for obj, items in status.items():
if obj in self.printer_state:
self.printer_state[obj].update(items)
else:
self.printer_state[obj] = items
def paneldue_beep(self, frequency, duration):
def paneldue_beep(self, frequency: int, duration: float) -> None:
duration = int(duration * 1000.)
self.write_response(
{'beep_freq': frequency, 'beep_length': duration})
def process_line(self, line):
def process_line(self, line: str) -> None:
self.debug_queue.append(line)
# If we find M112 in the line then skip verification
if "M112" in line.upper():
@ -328,7 +361,7 @@ class PanelDue:
# Get line number
line_index = line.find(' ')
try:
line_no = int(line[1:line_index])
line_no: Optional[int] = int(line[1:line_index])
except Exception:
line_index = -1
line_no = None
@ -370,7 +403,7 @@ class PanelDue:
# Check for commands that query state and require immediate response
if cmd in self.direct_gcodes:
params = {}
params: Dict[str, Any] = {}
for p in parts[1:]:
if p[0] not in "PSR":
params["arg_p"] = p.strip(" \"\t\n")
@ -391,20 +424,20 @@ class PanelDue:
# Prepare GCodes that require special handling
if cmd in self.special_gcodes:
func = self.special_gcodes[cmd]
script = func(parts[1:])
sgc_func = self.special_gcodes[cmd]
script = sgc_func(parts[1:])
if not script:
return
self.queue_gcode(script)
def queue_gcode(self, script):
def queue_gcode(self, script: str) -> None:
self.gc_queue.append(script)
if not self.gq_busy:
self.gq_busy = True
self.ioloop.spawn_callback(self._process_gcode_queue)
async def _process_gcode_queue(self):
async def _process_gcode_queue(self) -> None:
while self.gc_queue:
script = self.gc_queue.pop(0)
try:
@ -418,24 +451,24 @@ class PanelDue:
logging.exception(msg)
self.gq_busy = False
def queue_command(self, cmd, *args, **kwargs):
def queue_command(self, cmd: FlexCallback, *args, **kwargs) -> None:
self.command_queue.append((cmd, args, kwargs))
if not self.cq_busy:
self.cq_busy = True
self.ioloop.spawn_callback(self._process_command_queue)
async def _process_command_queue(self):
async def _process_command_queue(self) -> None:
while self.command_queue:
cmd, args, kwargs = self.command_queue.pop(0)
try:
ret = cmd(*args, **kwargs)
if asyncio.iscoroutine(ret):
if ret is not None:
await ret
except Exception:
logging.exception("Error processing command")
self.cq_busy = False
def _clean_filename(self, filename):
def _clean_filename(self, filename: str) -> str:
# Remove quotes and whitespace
filename.strip(" \"\t\n")
# Remove drive number
@ -453,17 +486,17 @@ class PanelDue:
filename = "/" + filename
return filename
def _prepare_M23(self, args):
def _prepare_M23(self, args: List[str]) -> str:
filename = self._clean_filename(args[0])
return f"M23 {filename}"
def _prepare_M32(self, args):
def _prepare_M32(self, args: List[str]) -> str:
filename = self._clean_filename(args[0])
# Escape existing double quotes in the file name
filename = filename.replace("\"", "\\\"")
return f"SDCARD_PRINT_FILE FILENAME=\"{filename}\""
def _prepare_M98(self, args):
def _prepare_M98(self, args: List[str]) -> str:
macro = args[0][1:].strip(" \"\t\n")
name_start = macro.rfind('/') + 1
macro = macro[name_start:]
@ -475,12 +508,12 @@ class PanelDue:
cmd = ""
return cmd
def _prepare_M290(self, args):
def _prepare_M290(self, args: List[str]) -> str:
# args should in in the format Z0.02
offset = args[0][1:].strip()
return f"SET_GCODE_OFFSET Z_ADJUST={offset} MOVE=1"
def _prepare_M292(self, args):
def _prepare_M292(self, args: List[str]) -> str:
p_val = int(args[0][1])
if p_val == 0:
cmd = self.confirmed_gcode
@ -488,13 +521,13 @@ class PanelDue:
return cmd
return ""
def _create_confirmation(self, name, gcode):
def _create_confirmation(self, name: str, gcode: str) -> None:
self.mbox_sequence += 1
self.confirmed_gcode = gcode
title = "Confirmation Dialog"
msg = f"Please confirm your intent to run {name}." \
" Press OK to continue, or CANCEL to abort."
mbox = {}
mbox: Dict[str, Any] = {}
mbox['msgBox.mode'] = 3
mbox['msgBox.msg'] = msg
mbox['msgBox.seq'] = self.mbox_sequence
@ -504,7 +537,7 @@ class PanelDue:
logging.debug(f"Creating PanelDue Confirmation: {mbox}")
self.write_response(mbox)
def handle_gcode_response(self, response):
def handle_gcode_response(self, response: str) -> None:
# Only queue up "non-trivial" gcode responses. At the
# moment we'll handle state changes and errors
if "Klipper state" in response \
@ -516,11 +549,11 @@ class PanelDue:
self.last_gcode_response = response
return
def write_response(self, response):
def write_response(self, response: Dict[str, Any]) -> None:
byte_resp = json.dumps(response) + "\r\n"
self.ser_conn.send(byte_resp.encode())
def _get_printer_status(self):
def _get_printer_status(self) -> str:
# PanelDue States applicable to Klipper:
# I = idle, P = printing from SD, S = stopped (shutdown),
# C = starting up (not ready), A = paused, D = pausing,
@ -529,6 +562,7 @@ class PanelDue:
return 'S'
printer_state = self.printer_state
sd_state: str
sd_state = printer_state['print_stats'].get('state', "standby")
if sd_state == "printing":
if self.last_printer_state == 'A':
@ -548,8 +582,11 @@ class PanelDue:
return 'I'
def _run_paneldue_M408(self, arg_r=None, arg_s=1):
response = {}
def _run_paneldue_M408(self,
arg_r: Optional[int] = None,
arg_s: int = 1
) -> None:
response: Dict[str, Any] = {}
sequence = arg_r
response_type = arg_s
@ -587,6 +624,9 @@ class PanelDue:
'homing_origin', [0., 0., 0., 0.])[2], 3)
# Current position
pos: List[float]
homed_pos: str
sfactor: float
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', "")
@ -597,38 +637,41 @@ class PanelDue:
# 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')
fname: str = print_stats.get('filename', "")
sd_print_state: Optional[str] = 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: float = 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)
est_time: float = 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: Optional[float]
est_total_fil = self.file_metadata.get('filament_total')
if est_total_fil:
cur_filament = print_stats.get('filament_used', 0.)
cur_filament: float = 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: Optional[float]
obj_height = self.file_metadata.get('object_height')
if obj_height:
cur_height = p_state['gcode_move'].get(
cur_height: float = p_state['gcode_move'].get(
'gcode_position', [0., 0., 0., 0.])[2]
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.)
duration: float = print_stats.get('print_duration', 0.)
times_left = [int(duration / progress - duration)]
response['timesLeft'] = times_left
else:
@ -636,11 +679,12 @@ class PanelDue:
self.current_file = ""
self.file_metadata = {}
fan_speed = p_state['fan'].get('speed')
fan_speed: Optional[float] = 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: Optional[str]
extruder_name = p_state['toolhead'].get('extruder')
if extruder_name is not None:
tool = 0
@ -649,12 +693,12 @@ class PanelDue:
response['tool'] = tool
# Report Heater Status
efactor = round(p_state['gcode_move'].get(
efactor: float = round(p_state['gcode_move'].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)
temp: float = round(p_state[name].get('temperature', 0.0), 1)
target: float = round(p_state[name].get('target', 0.0), 1)
response.setdefault('heaters', []).append(temp)
response.setdefault('active', []).append(target)
response.setdefault('standby', []).append(target)
@ -664,7 +708,7 @@ class PanelDue:
response.setdefault('extr', []).append(round(pos[3], 2))
# Display message (via M117)
msg = p_state['display_status'].get('message')
msg: str = 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
@ -673,7 +717,7 @@ class PanelDue:
self.last_message = msg
self.write_response(response)
def _run_paneldue_M20(self, arg_p, arg_s=0):
def _run_paneldue_M20(self, arg_p: str, arg_s: int = 0) -> None:
response_type = arg_s
if response_type != 2:
logging.info(
@ -689,7 +733,7 @@ class PanelDue:
# ie. "0:/"
if path.startswith("0:/"):
path = path[2:]
response = {'dir': path}
response: Dict[str, Any] = {'dir': path}
response['files'] = []
if path == "/macros":
@ -713,7 +757,7 @@ class PanelDue:
response['files'] = flist
self.write_response(response)
async def _run_paneldue_M30(self, arg_p=None):
async def _run_paneldue_M30(self, arg_p: str = "") -> None:
# Delete a file. Clean up the file name and make sure
# it is relative to the "gcodes" root.
path = arg_p
@ -727,9 +771,9 @@ class PanelDue:
path = "gcodes/" + path
await self.file_manager.delete_file(path)
def _run_paneldue_M36(self, arg_p=None):
response = {}
filename = arg_p
def _run_paneldue_M36(self, arg_p: Optional[str] = None) -> None:
response: Dict[str, Any] = {}
filename: Optional[str] = arg_p
sd_status = self.printer_state.get('virtual_sdcard', {})
print_stats = self.printer_state.get('print_stats', {})
if filename is None:
@ -756,37 +800,38 @@ class PanelDue:
if not filename.startswith("gcodes/"):
filename = "gcodes/" + filename
metadata = self.file_manager.get_file_metadata(filename)
metadata: Dict[str, Any] = \
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" + time.ctime(metadata['modified'])
slicer = metadata.get('slicer')
slicer: Optional[str] = metadata.get('slicer')
if slicer is not None:
response['generatedBy'] = slicer
height = metadata.get('object_height')
height: Optional[float] = metadata.get('object_height')
if height is not None:
response['height'] = round(height, 2)
layer_height = metadata.get('layer_height')
layer_height: Optional[float] = metadata.get('layer_height')
if layer_height is not None:
response['layerHeight'] = round(layer_height, 2)
filament = metadata.get('filament_total')
filament: Optional[float] = metadata.get('filament_total')
if filament is not None:
response['filament'] = [round(filament, 1)]
est_time = metadata.get('estimated_time')
est_time: Optional[float] = metadata.get('estimated_time')
if est_time is not None:
response['printTime'] = int(est_time + .5)
else:
response['err'] = 1
self.write_response(response)
def close(self):
def close(self) -> None:
self.ser_conn.disconnect()
msg = "\nPanelDue GCode Dump:"
for i, gc in enumerate(self.debug_queue):
msg += f"\nSequence {i}: {gc}"
logging.debug(msg)
def load_component(config):
def load_component(config: ConfigHelper) -> PanelDue:
return PanelDue(config)