From 71c3d53d6ee85a3e0c20fe2350e5e94dab2eb5aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Concei=C3=A7=C3=A3o?= Date: Mon, 22 Apr 2024 22:41:00 +0100 Subject: [PATCH 01/32] mSLA capabilities - Add manufacturing process - Send images/buffers via HDMI - Allow print from zip and tar files (By decompress them upfront) --- .gitignore | 1 + klippy/extras/framebuffer_display.py | 287 +++++++++++++++++++++++++++ klippy/extras/msla_display.py | 266 +++++++++++++++++++++++++ klippy/extras/virtual_sdcard.py | 111 ++++++++++- klippy/toolhead.py | 25 +++ scripts/klippy-requirements.txt | 1 + 6 files changed, 689 insertions(+), 2 deletions(-) create mode 100644 klippy/extras/framebuffer_display.py create mode 100644 klippy/extras/msla_display.py diff --git a/.gitignore b/.gitignore index f9521672a..bf422d2a2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ out *.so *.pyc +.idea .config .config.old klippy/.version diff --git a/klippy/extras/framebuffer_display.py b/klippy/extras/framebuffer_display.py new file mode 100644 index 000000000..d29ae0b61 --- /dev/null +++ b/klippy/extras/framebuffer_display.py @@ -0,0 +1,287 @@ +# mSLA and DLP display properties +# +# Copyright (C) 2024 Tiago Conceição +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import io +import logging +import math +import re +import os +import mmap +import time + +from PIL import Image +import threading + + +class FramebufferDisplay(): + def __init__(self, config): + self.printer = config.get_printer() + + self.model = config.get('model', None) + atypes = {'Mono': 'Mono', 'RGB': 'RGB', 'RGBA': 'RGBA'} + self.type = config.getchoice('type', atypes, default='Mono') + self.framebuffer_index = config.getint('framebuffer_index', minval=0) + if not os.path.exists(self.get_framebuffer_path()): + msg = f"The frame buffer device {self.get_framebuffer_path()} does not exists." + logging.exception(msg) + raise config.error(msg) + + self.resolution_x = config.getint('resolution_x', minval=1) # In pixels + self.resolution_y = config.getint('resolution_y', minval=1) # In pixels + self.display_width = config.getfloat('display_width', 0., above=0.) # In millimeters + self.display_height = config.getfloat('display_height', 0., above=0.) # In millimeters + self.pixel_width = config.getfloat('pixel_width', 0., above=0.) # In millimeters + self.pixel_height = config.getfloat('pixel_height', 0., above=0.) # In millimeters + + if self.display_width == 0. and self.pixel_width == 0.: + msg = "Either display_width or pixel_width must be provided." + logging.exception(msg) + raise config.error(msg) + + if self.display_height == 0. and self.pixel_height == 0.: + msg = "Either display_height or pixel_height must be provided." + logging.exception(msg) + raise config.error(msg) + + if self.display_width == 0.: + self.display_width = round(self.resolution_x * self.pixel_width, 4) + elif self.pixel_width == 0.: + self.pixel_width = round(self.display_width / self.resolution_x, 5) + + if self.display_height == 0.: + self.display_height = round(self.resolution_y * self.pixel_height, 4) + elif self.pixel_height == 0.: + self.pixel_height = round(self.display_height / self.resolution_y, 5) + + # Calculated values + self.bit_depth = 8 if self.type == 'Mono' else 24 + self.is_landscape = self.resolution_x >= self.resolution_y + self.is_portrait = not self.is_landscape + self.diagonal_inch = round(math.sqrt(self.display_width ** 2 + self.display_height ** 2) / 25.4, 2) + + # get modes width and height + with open(f"/sys/class/graphics/fb{self.framebuffer_index}/modes", "r") as f: + modes = f.read() + match = re.search(r"(\d+)x(\d+)", modes) + if match: + self.fb_modes_width = int(match.group(1)) + self.fb_modes_height = int(match.group(2)) + else: + msg = f"Unable to extract \"modes\" information from fame buffer device fb{self.framebuffer_index}." + logging.exception(msg) + raise config.error(msg) + + # get virtual width and height + with open(f"/sys/class/graphics/fb{self.framebuffer_index}/virtual_size", "r") as f: + virtual_size = f.read() + width_string, height_string = virtual_size.split(',') + self.fb_virtual_width = int(width_string) # width + self.fb_virtual_height = int(height_string) # height + + # get stride + with open(f"/sys/class/graphics/fb{self.framebuffer_index}/stride", "r") as f: + self.fb_stride = int(f.read()) + + # get bits per pixel + with open(f"/sys/class/graphics/fb{self.framebuffer_index}/bits_per_pixel", "r") as f: + self.fb_bits_per_pixel = int(f.read()) + + # Check if configured resolutions match framebuffer information + if self.resolution_x not in (self.fb_stride, self.fb_modes_width): + msg = f"The configured resolution_x of {self.resolution_x} does not match any of the framebuffer stride of ({self.fb_stride}) nor the modes of ({self.fb_modes_width})." + logging.exception(msg) + raise config.error(msg) + if self.resolution_y != self.fb_modes_height: + msg = f"The configured resolution_y of {self.resolution_y} does not match the framebuffer modes of ({self.fb_modes_height})." + logging.exception(msg) + raise config.error(msg) + + self.fb_maxsize = self.fb_stride * self.fb_modes_height + + # Defines the current thread that hold the display session to the framebuffer + self._framebuffer_thread = None + self.is_busy = False + + def get_framebuffer_path(self) -> str: + """ + Gets the framebuffer device path associated with this object. + @return: /dev/fb[i] + """ + return f"/dev/fb{self.framebuffer_index}" + + def wait_framebuffer_thread(self, timeout=None): + """ + Wait for the framebuffer thread to terminate (Finish writes). + @param timeout: Timeout time to wait for framebuffer thread to terminate. + """ + if self._framebuffer_thread is not None and self._framebuffer_thread.is_alive(): + self._framebuffer_thread.join(timeout) # Wait for other render completion + + def get_image_buffer(self, path) -> bytes: + """ + Reads an image from disk and return it buffer, ready to send. + Note that it does not do any file check, it will always try to open an image file. + @param path: Image path + @return: bytes buffer + """ + with Image.open(path) as img: + return img.tobytes() + + def clear_framebuffer(self): + """ + Clears the display with zeros (black background). + """ + self.is_busy = True + with open(self.get_framebuffer_path(), mode='r+b') as framebuffer_device: # open R/W + with mmap.mmap(framebuffer_device.fileno(), self.fb_stride * self.fb_modes_height, mmap.MAP_SHARED, + mmap.PROT_WRITE | mmap.PROT_READ) as framebuffer_memory_map: + color = 0 + framebuffer_memory_map.write(color.to_bytes(1, byteorder='little') * self.fb_maxsize) + self.is_busy = False + + def clear_framebuffer_threaded(self, wait_thread=False): + """ + Wait for the framebuffer thread to terminate (Finish writes). + @param wait_thread: Wait for the framebuffer thread to terminate. + """ + self.wait_framebuffer_thread() + self._framebuffer_thread = threading.Thread(target=self.clear_framebuffer, + name=f"Clears the fb{self.framebuffer_index}") + self._framebuffer_thread.start() + if wait_thread: + self._framebuffer_thread.join() + + def send_buffer(self, buffer, clear_first=False, offset=0): + """ + Send a byte array to the framebuffer device. + @param buffer: A 1D byte array with data to send + @param clear_first: If true first clears the display and then show the image. + @param offset: Sets a positive offset from start position of the buffer. + """ + if buffer is None or len(buffer) == 0: + return + + if not isinstance(buffer, (list, bytes)): + msg = f"The buffer must be a 1D byte array. {type(buffer)} was passed" + logging.exception(msg) + raise Exception(msg) + + if offset < 0: + msg = f"The offset {offset} can not be negative value." + logging.exception(msg) + raise Exception(msg) + + # open framebuffer and map it onto a python bytearray + self.is_busy = True + with open(self.get_framebuffer_path(), mode='r+b') as framebuffer_device: # open R/W + with mmap.mmap(framebuffer_device.fileno(), self.fb_stride * self.fb_modes_height, mmap.MAP_SHARED, + mmap.PROT_WRITE | mmap.PROT_READ) as framebuffer_memory_map: + if clear_first: + color = 0 + framebuffer_memory_map.write(color.to_bytes(1, byteorder='little') * self.fb_maxsize) + + buffer_size = len(buffer) + if buffer_size + offset > self.fb_maxsize: + msg = f"The buffer size of {buffer_size} + {offset} is larger than actual framebuffer size of {self.fb_maxsize}." + logging.exception(msg) + raise Exception(msg) + + if offset > 0: + framebuffer_memory_map.seek(offset) + + framebuffer_memory_map.write(buffer) + self.is_busy = False + + def send_buffer_threaded(self, buffer, clear_first=False, offset=0, wait_thread=False): + """ + Send a byte array to the framebuffer device in a separate thread. + @param buffer: A 1D byte array with data to send + @param clear_first: If true first clears the display and then show the image. + @param offset: Sets a positive offset from start position of the buffer. + @param wait_thread: If true wait for the framebuffer thread to terminate + """ + self.wait_framebuffer_thread() + self._framebuffer_thread = threading.Thread(target=lambda: self.send_buffer(buffer, clear_first, offset), + name=f"Sends an buffer to fb{self.framebuffer_index}") + self._framebuffer_thread.start() + if wait_thread: + self._framebuffer_thread.join() + + def send_image(self, path, clear_first=False, offset=0): + """ + Reads an image from a path and sends the bitmap to the framebuffer device. + @param path: Image file path to be read. + @param clear_first: If true first clears the display and then show the image. + @param offset: Sets a positive offset from start position of the buffer. + """ + if offset < 0: + msg = f"The offset {offset} can not be negative value." + logging.exception(msg) + raise Exception(msg) + + if not isinstance(path, str): + msg = f"Path must be a string." + logging.exception(msg) + raise Exception(msg) + + if not os.path.isfile(path): + msg = f"The file '{path}' does not exists." + logging.exception(msg) + raise Exception(msg) + + self.is_busy = True + buffer = self.get_image_buffer(path) + self.send_buffer(buffer, clear_first, offset) + + def send_image_threaded(self, path, clear_first=False, offset=0, wait_thread=False): + """ + Reads an image from a path and sends the bitmap to the framebuffer device. + @param path: Image file path to be read. + @param clear_first: If true first clears the display and then show the image. + @param offset: Sets a positive offset from start position of the buffer. + @param wait_thread: If true wait for the framebuffer thread to terminate. + """ + self.wait_framebuffer_thread() + self._framebuffer_thread = threading.Thread(target=lambda: self.send_image(path, clear_first, offset), + name=f"Render an image to fb{self.framebuffer_index}") + self._framebuffer_thread.start() + if wait_thread: + self._framebuffer_thread.join() + + +class FramebufferDisplayWrapper(FramebufferDisplay): + def __init__(self, config): + super().__init__(config) + + device_name = config.get_name().split()[1] + gcode = self.printer.lookup_object('gcode') + gcode.register_mux_command("FRAMEBUFFER_CLEAR", "DEVICE", device_name, self.cmd_FRAMEBUFFER_CLEAR, + desc=self.cmd_FRAMEBUFFER_CLEAR_help) + gcode.register_mux_command("FRAMEBUFFER_SEND_IMAGE", "DEVICE", device_name, self.cmd_FRAMEBUFFER_SEND_IMAGE, + desc=self.cmd_FRAMEBUFFER_SEND_IMAGE_help) + + + cmd_FRAMEBUFFER_CLEAR_help = "Clears the display with zeros (black background)" + def cmd_FRAMEBUFFER_CLEAR(self, gcmd): + wait_thread = gcmd.get_int('WAIT', 0, 0, 1) + + self.clear_framebuffer_threaded(wait_thread) + + cmd_FRAMEBUFFER_SEND_IMAGE_help = "Send a image from a path and display it on the framebuffer." + def cmd_FRAMEBUFFER_SEND_IMAGE(self, gcmd): + path = gcmd.get('PATH') + offset = gcmd.get_int('OFFSET', 0, 0) + clear_first = gcmd.get_int('CLEAR', 0, 0, 1) + wait_thread = gcmd.get_int('WAIT', 0, 0, 1) + + if not os.path.isfile(path): + raise gcmd.error(f"The file '{path}' does not exists.") + + self.send_image_threaded(path, clear_first, offset, wait_thread) + + +def load_config_prefix(config): + return FramebufferDisplayWrapper(config) diff --git a/klippy/extras/msla_display.py b/klippy/extras/msla_display.py new file mode 100644 index 000000000..19679548c --- /dev/null +++ b/klippy/extras/msla_display.py @@ -0,0 +1,266 @@ +# mSLA and DLP display properties +# +# Copyright (C) 2024 Tiago Conceição +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# + +import logging +import os +import re +import threading +import time +import tempfile +from pathlib import Path +from PIL import Image +from . import framebuffer_display + +CACHE_MIN_FREE_RAM = 256 # 256 MB + + +class mSLADisplay(framebuffer_display.FramebufferDisplay): + """ + Represents an mSLA display with an associated UV LED + """ + + def __init__(self, config): + super().__init__(config) + self.buffer_cache_count = config.getint('cache', 0, 0, 100) + + # RAM guard + if self.buffer_cache_count > 0 and os.path.isfile('/proc/meminfo'): + with open('/proc/meminfo', 'r') as mem: + for i in mem: + sline = i.split() + if str(sline[0]) == 'MemFree:': + free_memory_mb = int(sline[1]) / 1024. + buffer_max_size = (self.buffer_cache_count + 1) * self.fb_maxsize / 1024. / 1024. + if free_memory_mb - buffer_max_size < CACHE_MIN_FREE_RAM: + msg = ("The current cache count of %d requires %.2f MB of RAM, currently have %.2f MB of " + "free memory. It requires at least a margin of %.2f MB of free RAM.\n" + "Please reduce the cache count.") % ( + self.buffer_cache_count, buffer_max_size, free_memory_mb, CACHE_MIN_FREE_RAM) + logging.exception(msg) + raise config.error(msg) + break + + self._cache = [] + self._cache_thread = None + + self.uvled_output_pin_name = config.get('uvled_output_pin_name', 'msla_uvled') + self.uvled = self.printer.lookup_object(f"output_pin {self.uvled_output_pin_name}") + + self._sd = self.printer.lookup_object("virtual_sdcard") + + # Events + self.printer.register_event_handler("virtual_sdcard:reset_file", self.clear_cache) + + # Register commands + gcode = self.printer.lookup_object('gcode') + gcode.register_command("MSLA_DISPLAY_CLEAR", self.cmd_MSLA_DISPLAY_CLEAR, + desc=self.cmd_MSLA_DISPLAY_CLEAR_help) + gcode.register_command("MSLA_DISPLAY_IMAGE", self.cmd_MSLA_DISPLAY_IMAGE, + desc=self.cmd_MSLA_DISPLAY_IMAGE_help) + gcode.register_command("MSLA_DISPLAY_RESPONSE_TIME", self.cmd_MSLA_DISPLAY_RESPONSE_TIME, + desc=self.cmd_MSLA_DISPLAY_RESPONSE_TIME_help) + + gcode.register_command('M1400', self.cmd_M1400, desc=self.cmd_M1400_help) + + def get_status(self, eventtime): + return {'display_busy': self.is_busy, + 'uvled_value': self.uvled.last_value * 255.} + + def clear_cache(self): + """ + Clear all cached buffers + """ + self._cache.clear() + + def _can_cache(self) -> bool: + """ + Returns true if is ready to cache + @rtype: bool + """ + return self.buffer_cache_count > 0 and (self._cache_thread is None or not self._cache_thread.is_alive()) + + def _wait_cache_thread(self) -> bool: + """ + Waits for cache thread completion + @rtype: bool + @return: True if waited, otherwise false + """ + if self._cache_thread is not None and self._cache_thread.is_alive(): + self._cache_thread.join() + return True + return False + + def _process_cache(self, last_cache): + """ + Cache next items from last cache position. + @param last_cache: + """ + # Dequeue items + while len(self._cache) >= self.buffer_cache_count: + self._cache.pop(0) + + cache_len = len(self._cache) + if cache_len: + last_cache = self._cache[-1] + + # Cache new items + item_count = self.buffer_cache_count - cache_len + layer_index = last_cache.get_layer_index() + for i in range(item_count): + layer_index += 1 + new_path = last_cache.new_path_layerindex(layer_index) + if os.path.isfile(new_path): + buffer = self.get_image_buffer(new_path) + self._cache.append(BufferCache(new_path, buffer)) + + def _get_cache_index(self, path) -> int: + """ + Gets the index of the cache position on the list given a path + @param path: Path to seek + @return: Index of the cache on the list, otherwise -1 + """ + for i, cache in enumerate(self._cache): + if cache.path == path: + return i + return -1 + + def _get_image_buffercache(self, gcmd, path) -> bytes: + """ + Gets a image buffer from cache if available, otherwise it creates the buffer from the path image + @param gcmd: + @param path: Image path + @return: Image buffer + """ + # If currently printing, use print directory to select the file + current_filepath = self._sd.file_path() + if current_filepath is not None and path[0] != '/': + path = os.path.join(os.path.dirname(current_filepath), path) + if not os.path.isfile(path): + raise gcmd.error(f"M1400: The file '{path}' does not exists.") + + if self.buffer_cache_count > 0: + self._wait_cache_thread() # May be worth to wait if streaming images + i = self._get_cache_index(path) + #gcmd.respond_raw(f"File in cache: {i}, cache size: {len(self._cache)}, Can cache: {self._can_cache()}") + if i >= 0: + cache = self._cache[i] + # RTrim up to cache + self._cache = self._cache[i + 1:] + + if self._can_cache(): + self._cache_thread = threading.Thread(target=self._process_cache, args=(cache,)) + self._cache_thread.start() + + return cache.buffer + + if not os.path.isfile(path): + raise gcmd.error(f"M1400: The file '{path}' does not exists.") + + buffer = self.get_image_buffer(path) + if self._can_cache(): + cache = BufferCache(path, buffer) + self._cache_thread = threading.Thread(target=self._process_cache, args=(cache,)) + self._cache_thread.start() + + return buffer + + cmd_MSLA_DISPLAY_CLEAR_help = "Clears the display by send a full buffer of zeros." + + def cmd_MSLA_DISPLAY_CLEAR(self, gcmd): + wait_thread = gcmd.get_int('WAIT', 0, 0, 1) + self.clear_framebuffer_threaded(wait_thread) + + cmd_MSLA_DISPLAY_IMAGE_help = "Sends a image from a path and display it on the main display." + + def cmd_MSLA_DISPLAY_IMAGE(self, gcmd): + path = gcmd.get('PATH') + clear_first = gcmd.get_int('CLEAR', 0, 0, 1) + offset = gcmd.get_int('OFFSET', 0, 0) + wait_thread = gcmd.get_int('WAIT', 0, 0, 1) + path = os.path.normpath(os.path.expanduser(path)) + + times = time.time() + buffer = self._get_image_buffercache(gcmd, path) + self.send_buffer_threaded(buffer, clear_first, offset, wait_thread) + gcmd.respond_raw("Get/cache buffer: %.2f ms" % ((time.time() - times) * 1000.,)) + + cmd_MSLA_DISPLAY_RESPONSE_TIME_help = "Send a buffer to display and test it response time to fill that buffer." + + def cmd_MSLA_DISPLAY_RESPONSE_TIME(self, gcmd): + avg = gcmd.get_int('AVG', 1, 1, 20) + + gcmd.respond_raw( + 'Buffer size: %d bytes (%.2f MB)' % (self.fb_maxsize, round(self.fb_maxsize / 1024.0 / 1024.0, 2))) + + time_sum = 0 + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, 'buffer.png') + with Image.new('L', (self.resolution_x, self.resolution_y), color='black') as image: + image.save(path, "PNG") + for _ in range(avg): + timems = time.time() + self.send_image(path) + time_sum += time.time() - timems + gcmd.respond_raw('Read image from disk + send time: %fms (%d samples)' % (round(time_sum * 1000 / avg, 6), avg)) + + time_sum = 0 + buffer = bytes(os.urandom(self.fb_maxsize)) + for _ in range(avg): + timems = time.time() + self.send_buffer(buffer) + time_sum += time.time() - timems + gcmd.respond_raw('Send cached buffer: %fms (%d samples)' % (round(time_sum * 1000 / avg, 6), avg)) + + time_sum = 0 + for _ in range(avg): + timems = time.time() + self.clear_framebuffer() + time_sum += time.time() - timems + gcmd.respond_raw('Clear time: %fms (%d samples)' % (round(time_sum * 1000 / avg, 6), avg)) + + cmd_M1400_help = "Turn the main UV LED to cure the pixels." + + def cmd_M1400(self, gcmd): + """ + Turn the main UV LED to cure the pixels. + M1400 comes from the wavelength of UV radiation (UVR) lies in the range of 100–400 nm, + and is further subdivided into UVA (315–400 nm), UVB (280–315 nm), and UVC (100–280 nm). + @param gcmd: + """ + value = gcmd.get_float('S', minval=0., maxval=255.) + if self.uvled.is_pwm: + value /= 255.0 + else: + value = 1 if value else 0 + + if value > 0: + # Sync display + self.wait_framebuffer_thread() + + toolhead = self.printer.lookup_object('toolhead') + toolhead.register_lookahead_callback(lambda print_time: self.uvled._set_pin(print_time, value)) + + +class BufferCache(): + def __init__(self, path, buffer): + self.path = path + self.buffer = buffer + self.pathinfo = Path(path) + + def new_path_layerindex(self, n): + return re.sub(rf"\d+\{self.pathinfo.suffix}$", f"{n}{self.pathinfo.suffix}", self.path) + + def get_layer_index(self): + match = re.search(rf"(\d+)\{self.pathinfo.suffix}$", self.path) + if match is None: + return None + + return int(match.group(1)) + + +def load_config(config): + return mSLADisplay(config) diff --git a/klippy/extras/virtual_sdcard.py b/klippy/extras/virtual_sdcard.py index 6dc49e2f5..8728c4594 100644 --- a/klippy/extras/virtual_sdcard.py +++ b/klippy/extras/virtual_sdcard.py @@ -4,8 +4,12 @@ # # This file may be distributed under the terms of the GNU GPLv3 license. import os, sys, logging, io +import hashlib, shutil +import zipfile, tarfile +import time VALID_GCODE_EXTS = ['gcode', 'g', 'gco'] +VALID_ARCHIVE_EXTS = ['zip', 'tar'] DEFAULT_ERROR_GCODE = """ {% if 'heaters' in printer %} @@ -13,6 +17,8 @@ DEFAULT_ERROR_GCODE = """ {% endif %} """ +DEFAULT_ARCHIVE_HASH_FILENAME = 'hash.md5' + class VirtualSD: def __init__(self, config): self.printer = config.get_printer() @@ -21,6 +27,13 @@ class VirtualSD: # sdcard state sd = config.get('path') self.sdcard_dirname = os.path.normpath(os.path.expanduser(sd)) + + archive_temp_path = os.path.join(os.sep, 'tmp', 'klipper-archive-print-contents') + if self.sdcard_dirname.count(os.sep) >= 3: + archive_temp_path = os.path.normpath(os.path.expanduser(os.path.join(self.sdcard_dirname, '..', 'tmparchive'))) + + archive_temp_path = config.get('archive_temp_path', archive_temp_path) + self.sdcard_archive_temp_dirname = os.path.normpath(os.path.expanduser(archive_temp_path)) self.current_file = None self.file_position = self.file_size = 0 # Print Stat Tracking @@ -64,6 +77,20 @@ class VirtualSD: if self.work_timer is None: return False, "" return True, "sd_pos=%d" % (self.file_position,) + def get_archive_files(self, path): + if path.endswith('.zip'): + try: + with zipfile.ZipFile(path, "r") as file: + return file.namelist() + except: + pass + elif path.endswith('.tar'): + try: + with tarfile.open(path, "r") as file: + return file.getnames() + except: + pass + return [] def get_file_list(self, check_subdirs=False): if check_subdirs: flist = [] @@ -71,10 +98,24 @@ class VirtualSD: self.sdcard_dirname, followlinks=True): for name in files: ext = name[name.rfind('.')+1:] - if ext not in VALID_GCODE_EXTS: + if ext not in VALID_GCODE_EXTS + VALID_ARCHIVE_EXTS: continue + full_path = os.path.join(root, name) r_path = full_path[len(self.sdcard_dirname) + 1:] + + if ext in VALID_ARCHIVE_EXTS: + entries = self.get_archive_files(full_path) + + # Support only 1 gcode file as if theres more we unable to guess what to print + count = 0 + for entry in entries: + entry_ext = entry[entry.rfind('.') + 1:] + if entry_ext in VALID_GCODE_EXTS: + count += 1 + if count != 1: + continue + size = os.path.getsize(full_path) flist.append((r_path, size)) return sorted(flist, key=lambda f: f[0].lower()) @@ -182,11 +223,68 @@ class VirtualSD: try: if fname not in flist: fname = files_by_lower[fname.lower()] - fname = os.path.join(self.sdcard_dirname, fname) + + ext = fname[fname.rfind('.') + 1:] + if ext in VALID_ARCHIVE_EXTS: + need_extract = True + hashfile = os.path.join(self.sdcard_archive_temp_dirname, DEFAULT_ARCHIVE_HASH_FILENAME) + + gcmd.respond_raw(f"Calculating {fname} hash") + hash = self._file_hash(os.path.join(self.sdcard_dirname, fname)) + + if os.path.isfile(hashfile): + with open(hashfile, 'r') as f: + found_hash = f.readline() + if len(found_hash) == 32: + if hash == found_hash: + need_extract = False + + if ext == 'zip': + with zipfile.ZipFile(os.path.join(self.sdcard_dirname, fname), "r") as zip_file: + if need_extract: + if os.path.isdir(self.sdcard_archive_temp_dirname): + shutil.rmtree(self.sdcard_archive_temp_dirname) + gcmd.respond_raw(f"Decompressing {fname}...") + timenow = time.time() + zip_file.extractall(self.sdcard_archive_temp_dirname) + timenow = time.time() - timenow + gcmd.respond_raw(f"Decompress done in {timenow:.2f} seconds") + with open(hashfile, 'w') as f: + f.write(hash) + + entries = zip_file.namelist() + for entry in entries: + entry_ext = entry[entry.rfind('.') + 1:] + if entry_ext in VALID_GCODE_EXTS: + fname = os.path.join(os.path.join(self.sdcard_archive_temp_dirname, entry)) + break + elif ext == 'tar': + with tarfile.open(os.path.join(self.sdcard_dirname, fname), "r") as tar_file: + if need_extract: + if os.path.isdir(self.sdcard_archive_temp_dirname): + shutil.rmtree(self.sdcard_archive_temp_dirname) + gcmd.respond_raw(f"Decompressing {fname}...") + timenow = time.time() + tar_file.extractall(self.sdcard_archive_temp_dirname) + timenow = time.time() - timenow + gcmd.respond_raw(f"Decompress done in {timenow:.2f} seconds") + with open(hashfile, 'w') as f: + f.write(hash) + + entries = tar_file.getnames() + for entry in entries: + entry_ext = entry[entry.rfind('.') + 1:] + if entry_ext in VALID_GCODE_EXTS: + fname = os.path.join(os.path.join(self.sdcard_archive_temp_dirname, entry)) + break + else: + fname = os.path.join(self.sdcard_dirname, fname) + f = io.open(fname, 'r', newline='') f.seek(0, os.SEEK_END) fsize = f.tell() f.seek(0) + except: logging.exception("virtual_sdcard file open") raise gcmd.error("Unable to open file") @@ -303,6 +401,15 @@ class VirtualSD: else: self.print_stats.note_complete() return self.reactor.NEVER + def _file_hash(self, filepath, block_size=2**20) -> str: + md5 = hashlib.md5() + with open(filepath, "rb") as f: + while True: + data = f.read(block_size) + if not data: + break + md5.update(data) + return md5.hexdigest() def load_config(config): return VirtualSD(config) diff --git a/klippy/toolhead.py b/klippy/toolhead.py index 4149d53b1..25bfa17a8 100644 --- a/klippy/toolhead.py +++ b/klippy/toolhead.py @@ -214,6 +214,18 @@ class ToolHead: self.lookahead = LookAheadQueue(self) self.lookahead.set_flush_time(BUFFER_TIME_HIGH) self.commanded_pos = [0., 0., 0., 0.] + # The manufacturing process type + atypes = {'FDM': 'FDM', 'SLA': 'SLA', 'mSLA': 'mSLA', 'DLP': 'DLP'} + self.manufacturing_process = config.getchoice('manufacturing_process', atypes, default='FDM') + + if self.manufacturing_process in ('mSLA', 'DLP'): + for s in ('output_pin msla_uvled', 'msla_display'): + section = self.printer.lookup_object(s, None) + if section is None: + msg = "Error: A section with [%s] is required for mSLA/DLP printers." % (s,) + logging.exception(msg) + raise config.error(msg) + # Velocity and acceleration control self.max_velocity = config.getfloat('max_velocity', above=0.) self.max_accel = config.getfloat('max_accel', above=0.) @@ -282,6 +294,11 @@ class ToolHead: self.cmd_SET_VELOCITY_LIMIT, desc=self.cmd_SET_VELOCITY_LIMIT_help) gcode.register_command('M204', self.cmd_M204) + + gcode.register_command('QUERY_MANUFACTORING_PROCESS', self.cmd_QUERY_MANUFACTORING_PROCESS, + desc="Query manufacturing process") + + self.printer.register_event_handler("klippy:shutdown", self._handle_shutdown) # Load some default modules @@ -665,6 +682,14 @@ class ToolHead: self.max_accel = accel self._calc_junction_deviation() + def cmd_QUERY_MANUFACTORING_PROCESS(self, gcmd): + """ + Returns the manufacturing process + @param gcmd: + """ + gcmd.respond_raw(self.manufacturing_process) + + def add_printer_objects(config): config.get_printer().add_object('toolhead', ToolHead(config)) kinematics.extruder.add_printer_objects(config) diff --git a/scripts/klippy-requirements.txt b/scripts/klippy-requirements.txt index 7fb235404..9f3ce3b5d 100644 --- a/scripts/klippy-requirements.txt +++ b/scripts/klippy-requirements.txt @@ -9,3 +9,4 @@ greenlet==3.0.3 ; python_version >= '3.12' Jinja2==2.11.3 python-can==3.3.4 markupsafe==1.1.1 +pillow \ No newline at end of file From 62c4e71dcaf3a84e710ff3d7178b6cc3be45407f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Concei=C3=A7=C3=A3o?= Date: Tue, 23 Apr 2024 02:49:04 +0100 Subject: [PATCH 02/32] Use M6054 --- klippy/extras/msla_display.py | 121 +++++++++++++++++++++++++++------- 1 file changed, 99 insertions(+), 22 deletions(-) diff --git a/klippy/extras/msla_display.py b/klippy/extras/msla_display.py index 19679548c..8066c9e09 100644 --- a/klippy/extras/msla_display.py +++ b/klippy/extras/msla_display.py @@ -39,7 +39,7 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): msg = ("The current cache count of %d requires %.2f MB of RAM, currently have %.2f MB of " "free memory. It requires at least a margin of %.2f MB of free RAM.\n" "Please reduce the cache count.") % ( - self.buffer_cache_count, buffer_max_size, free_memory_mb, CACHE_MIN_FREE_RAM) + self.buffer_cache_count, buffer_max_size, free_memory_mb, CACHE_MIN_FREE_RAM) logging.exception(msg) raise config.error(msg) break @@ -57,13 +57,17 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): # Register commands gcode = self.printer.lookup_object('gcode') - gcode.register_command("MSLA_DISPLAY_CLEAR", self.cmd_MSLA_DISPLAY_CLEAR, - desc=self.cmd_MSLA_DISPLAY_CLEAR_help) - gcode.register_command("MSLA_DISPLAY_IMAGE", self.cmd_MSLA_DISPLAY_IMAGE, - desc=self.cmd_MSLA_DISPLAY_IMAGE_help) + gcode.register_command("MSLA_DISPLAY_VALIDATE", self.cmd_MSLA_DISPLAY_VALIDATE, + desc=self.cmd_MSLA_DISPLAY_VALIDATE_help) gcode.register_command("MSLA_DISPLAY_RESPONSE_TIME", self.cmd_MSLA_DISPLAY_RESPONSE_TIME, desc=self.cmd_MSLA_DISPLAY_RESPONSE_TIME_help) + #gcode.register_command("MSLA_DISPLAY_CLEAR", self.cmd_MSLA_DISPLAY_CLEAR, + # desc=self.cmd_MSLA_DISPLAY_CLEAR_help) + #gcode.register_command("MSLA_DISPLAY_IMAGE", self.cmd_MSLA_DISPLAY_IMAGE, + # desc=self.cmd_MSLA_DISPLAY_IMAGE_help) + + gcode.register_command('M6054', self.cmd_M6054, desc=self.cmd_M6054_help) gcode.register_command('M1400', self.cmd_M1400, desc=self.cmd_M1400_help) def get_status(self, eventtime): @@ -140,7 +144,7 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): if current_filepath is not None and path[0] != '/': path = os.path.join(os.path.dirname(current_filepath), path) if not os.path.isfile(path): - raise gcmd.error(f"M1400: The file '{path}' does not exists.") + raise gcmd.error(f"M6054: The file '{path}' does not exists.") if self.buffer_cache_count > 0: self._wait_cache_thread() # May be worth to wait if streaming images @@ -158,7 +162,7 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): return cache.buffer if not os.path.isfile(path): - raise gcmd.error(f"M1400: The file '{path}' does not exists.") + raise gcmd.error(f"M6054: The file '{path}' does not exists.") buffer = self.get_image_buffer(path) if self._can_cache(): @@ -168,25 +172,57 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): return buffer - cmd_MSLA_DISPLAY_CLEAR_help = "Clears the display by send a full buffer of zeros." + cmd_MSLA_DISPLAY_VALIDATE_help = "Validate the display against resolution and pixel parameters. Throw error if out of parameters." - def cmd_MSLA_DISPLAY_CLEAR(self, gcmd): - wait_thread = gcmd.get_int('WAIT', 0, 0, 1) - self.clear_framebuffer_threaded(wait_thread) + def cmd_MSLA_DISPLAY_VALIDATE(self, gcmd): + """ + Layer images are universal within same resolution and pixel pitch. Other printers with same resolution and pixel + pitch can print same file. This command ensure print only continue if requirements are meet. + @param gcmd: + @return: + """ + resolution = gcmd.get('RESOLUTION') + pixel_size = gcmd.get('PIXEL') - cmd_MSLA_DISPLAY_IMAGE_help = "Sends a image from a path and display it on the main display." + resolution_split = resolution.split(',', 1) + if len(resolution_split) < 2: + raise gcmd.error(f"The resolution of {resolution} is malformed, format: RESOLUTION_X,RESOLUTION_Y.") - def cmd_MSLA_DISPLAY_IMAGE(self, gcmd): - path = gcmd.get('PATH') - clear_first = gcmd.get_int('CLEAR', 0, 0, 1) - offset = gcmd.get_int('OFFSET', 0, 0) - wait_thread = gcmd.get_int('WAIT', 0, 0, 1) - path = os.path.normpath(os.path.expanduser(path)) + try: + resolution_x = int(resolution_split[0]) + except: + raise gcmd.error(f"The resolution x must be an integer number.") - times = time.time() - buffer = self._get_image_buffercache(gcmd, path) - self.send_buffer_threaded(buffer, clear_first, offset, wait_thread) - gcmd.respond_raw("Get/cache buffer: %.2f ms" % ((time.time() - times) * 1000.,)) + try: + resolution_y = int(resolution_split[1]) + except: + raise gcmd.error(f"The resolution y must be an integer.") + + if resolution_x != self.resolution_x: + raise gcmd.error(f"The resolution X of {resolution_x} is invalid. Should be {self.resolution_x}.") + + if resolution_y != self.resolution_y: + raise gcmd.error(f"The resolution Y of {resolution_y} is invalid. Should be {self.resolution_y}.") + + pixel_size_split = pixel_size.split(',', 1) + if len(pixel_size_split) < 2: + raise gcmd.error(f"The pixel size of {pixel_size} is malformed, format: PIXEL_WIDTH,PIXEL_HEIGHT.") + + try: + pixel_width = float(pixel_size_split[0]) + except: + raise gcmd.error(f"The pixel width must be an floating point number.") + + try: + pixel_height = float(pixel_size_split[1]) + except: + raise gcmd.error(f"The pixel height must be an floating point number.") + + if pixel_width != self.pixel_width: + raise gcmd.error(f"The pixel width of {pixel_width} is invalid. Should be {self.pixel_width}.") + + if pixel_height != self.pixel_height: + raise gcmd.error(f"The pixel height of {pixel_height} is invalid. Should be {self.pixel_height}.") cmd_MSLA_DISPLAY_RESPONSE_TIME_help = "Send a buffer to display and test it response time to fill that buffer." @@ -222,6 +258,47 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): time_sum += time.time() - timems gcmd.respond_raw('Clear time: %fms (%d samples)' % (round(time_sum * 1000 / avg, 6), avg)) + # Merged both into M6054 + # cmd_MSLA_DISPLAY_CLEAR_help = "Clears the display by send a full buffer of zeros." + # + # def cmd_MSLA_DISPLAY_CLEAR(self, gcmd): + # wait_thread = gcmd.get_int('WAIT', 0, 0, 1) + # self.clear_framebuffer_threaded(wait_thread) + # + # cmd_MSLA_DISPLAY_IMAGE_help = "Sends a image from a path and display it on the main display." + # + # def cmd_MSLA_DISPLAY_IMAGE(self, gcmd): + # path = gcmd.get('PATH') + # clear_first = gcmd.get_int('CLEAR', 0, 0, 1) + # offset = gcmd.get_int('OFFSET', 0, 0) + # wait_thread = gcmd.get_int('WAIT', 0, 0, 1) + # path = os.path.normpath(os.path.expanduser(path)) + # + # #times = time.time() + # buffer = self._get_image_buffercache(gcmd, path) + # self.send_buffer_threaded(buffer, clear_first, offset, wait_thread) + # #gcmd.respond_raw("Get/cache buffer: %.2f ms" % ((time.time() - times) * 1000.,)) + + cmd_M6054_help = "Sends a image from a path and display it on the main display. If no parameter it will clear the display" + + def cmd_M6054(self, gcmd): + clear_first = gcmd.get_int('C', 0, 0, 1) + offset = gcmd.get_int('O', 0, 0) + wait_thread = gcmd.get_int('W', 0, 0, 1) + + params = gcmd.get_raw_command_parameters().strip() + + match = re.search(r'F?(.+[.](png|jpg|jpeg|bmp|gif))', params) + if match: + path = match.group(1).strip(' "') + path = os.path.normpath(os.path.expanduser(path)) + buffer = self._get_image_buffercache(gcmd, path) + self.send_buffer_threaded(buffer, clear_first, offset, wait_thread) + else: + if 'F' in params: + raise gcmd.error(f"M6054: The F parameter is malformed. Use a proper image file.") + self.clear_framebuffer_threaded(wait_thread) + cmd_M1400_help = "Turn the main UV LED to cure the pixels." def cmd_M1400(self, gcmd): From dfa281f21b02ad9f8195e83cc79b30bc71d58693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Concei=C3=A7=C3=A3o?= Date: Tue, 23 Apr 2024 03:13:52 +0100 Subject: [PATCH 03/32] Clear the framebuffer on init --- klippy/extras/framebuffer_display.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/klippy/extras/framebuffer_display.py b/klippy/extras/framebuffer_display.py index d29ae0b61..1f9a6cb3e 100644 --- a/klippy/extras/framebuffer_display.py +++ b/klippy/extras/framebuffer_display.py @@ -105,6 +105,8 @@ class FramebufferDisplay(): self._framebuffer_thread = None self.is_busy = False + self.clear_framebuffer_threaded() + def get_framebuffer_path(self) -> str: """ Gets the framebuffer device path associated with this object. From 50f4feda4cbda78ad68750813c861812887545fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Concei=C3=A7=C3=A3o?= Date: Tue, 23 Apr 2024 03:16:23 +0100 Subject: [PATCH 04/32] Remove time import --- klippy/extras/framebuffer_display.py | 1 - 1 file changed, 1 deletion(-) diff --git a/klippy/extras/framebuffer_display.py b/klippy/extras/framebuffer_display.py index 1f9a6cb3e..f061f19f8 100644 --- a/klippy/extras/framebuffer_display.py +++ b/klippy/extras/framebuffer_display.py @@ -10,7 +10,6 @@ import math import re import os import mmap -import time from PIL import Image import threading From b757f62c117d4135d79927938cee66d584797e10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Concei=C3=A7=C3=A3o?= Date: Tue, 23 Apr 2024 03:18:22 +0100 Subject: [PATCH 05/32] Update klippy-requirements.txt --- scripts/klippy-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/klippy-requirements.txt b/scripts/klippy-requirements.txt index 9f3ce3b5d..922d474b5 100644 --- a/scripts/klippy-requirements.txt +++ b/scripts/klippy-requirements.txt @@ -9,4 +9,4 @@ greenlet==3.0.3 ; python_version >= '3.12' Jinja2==2.11.3 python-can==3.3.4 markupsafe==1.1.1 -pillow \ No newline at end of file +pillow From d7d3fad601778f5e0c586c7d4ea8bd62ca43c1e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Concei=C3=A7=C3=A3o?= Date: Tue, 23 Apr 2024 03:53:36 +0100 Subject: [PATCH 06/32] Remove output_pin check as it can be configurable with another name --- klippy/toolhead.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/klippy/toolhead.py b/klippy/toolhead.py index 25bfa17a8..61ac60d38 100644 --- a/klippy/toolhead.py +++ b/klippy/toolhead.py @@ -219,7 +219,7 @@ class ToolHead: self.manufacturing_process = config.getchoice('manufacturing_process', atypes, default='FDM') if self.manufacturing_process in ('mSLA', 'DLP'): - for s in ('output_pin msla_uvled', 'msla_display'): + for s in ('msla_display',): section = self.printer.lookup_object(s, None) if section is None: msg = "Error: A section with [%s] is required for mSLA/DLP printers." % (s,) From ea3b0f6d555779cbbdfdd5e6b3b2157079f32887 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Concei=C3=A7=C3=A3o?= Date: Tue, 23 Apr 2024 21:00:40 +0100 Subject: [PATCH 07/32] Improve M1400 with P[milliseconds] to better sync times --- klippy/extras/msla_display.py | 36 ++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/klippy/extras/msla_display.py b/klippy/extras/msla_display.py index 8066c9e09..d806d79ff 100644 --- a/klippy/extras/msla_display.py +++ b/klippy/extras/msla_display.py @@ -178,6 +178,11 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): """ Layer images are universal within same resolution and pixel pitch. Other printers with same resolution and pixel pitch can print same file. This command ensure print only continue if requirements are meet. + + Syntax: MSLA_DISPLAY_VALIDATE RESOLUTION= PIXEL= + + RESOLUTION: Machine LCD resolution + PIXEL: Machine LCD pixel pitch @param gcmd: @return: """ @@ -227,6 +232,15 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): cmd_MSLA_DISPLAY_RESPONSE_TIME_help = "Send a buffer to display and test it response time to fill that buffer." def cmd_MSLA_DISPLAY_RESPONSE_TIME(self, gcmd): + """ + Tests the display response time + + Syntax: MSLA_DISPLAY_RESPONSE_TIME AVG=[x] + + AVG: Number of samples to average (int) + @param gcmd: + @return: + """ avg = gcmd.get_int('AVG', 1, 1, 20) gcmd.respond_raw( @@ -282,6 +296,16 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): cmd_M6054_help = "Sends a image from a path and display it on the main display. If no parameter it will clear the display" def cmd_M6054(self, gcmd): + """ + Display an image from a path and display it on the main display. If no parameter it will clear the display + + Syntax: M6054 F<"file.png"> C[0/1] O[x] W[0/1] + F: File path eg: "1.png". quotes are optional. (str) + C: Clear the image before display the new image. (int) + W: Wait for buffer to complete the transfer. (int) + @param gcmd: + @return: + """ clear_first = gcmd.get_int('C', 0, 0, 1) offset = gcmd.get_int('O', 0, 0) wait_thread = gcmd.get_int('W', 0, 0, 1) @@ -290,7 +314,7 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): match = re.search(r'F?(.+[.](png|jpg|jpeg|bmp|gif))', params) if match: - path = match.group(1).strip(' "') + path = match.group(1).strip(' "\'') path = os.path.normpath(os.path.expanduser(path)) buffer = self._get_image_buffercache(gcmd, path) self.send_buffer_threaded(buffer, clear_first, offset, wait_thread) @@ -306,9 +330,15 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): Turn the main UV LED to cure the pixels. M1400 comes from the wavelength of UV radiation (UVR) lies in the range of 100–400 nm, and is further subdivided into UVA (315–400 nm), UVB (280–315 nm), and UVC (100–280 nm). + + Syntax: M1400 S<0-255> P[milliseconds] + S: LED Power (Non PWM LEDs will turn on from 1 to 255). (int) + P: Time to wait in milliseconds when (S>0) to turn off. (int) @param gcmd: """ value = gcmd.get_float('S', minval=0., maxval=255.) + delay = gcmd.get_float('P', 0, above=0.) / 1000. + if self.uvled.is_pwm: value /= 255.0 else: @@ -321,6 +351,10 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): toolhead = self.printer.lookup_object('toolhead') toolhead.register_lookahead_callback(lambda print_time: self.uvled._set_pin(print_time, value)) + if value > 0 and delay > 0: + toolhead.dwell(delay) + toolhead.register_lookahead_callback(lambda print_time: self.uvled._set_pin(print_time, 0)) + class BufferCache(): def __init__(self, path, buffer): From 725c104c564b50f1438273902858bf26a04e2f45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Concei=C3=A7=C3=A3o?= Date: Tue, 23 Apr 2024 21:17:10 +0100 Subject: [PATCH 08/32] Cache only if printing from file --- klippy/extras/msla_display.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/klippy/extras/msla_display.py b/klippy/extras/msla_display.py index d806d79ff..cef62e616 100644 --- a/klippy/extras/msla_display.py +++ b/klippy/extras/msla_display.py @@ -165,7 +165,7 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): raise gcmd.error(f"M6054: The file '{path}' does not exists.") buffer = self.get_image_buffer(path) - if self._can_cache(): + if current_filepath and self._can_cache(): cache = BufferCache(path, buffer) self._cache_thread = threading.Thread(target=self._process_cache, args=(cache,)) self._cache_thread.start() From 8972eb73ba6dbe6920aebec631464da88cb11a11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Concei=C3=A7=C3=A3o?= Date: Thu, 25 Apr 2024 23:54:31 +0100 Subject: [PATCH 09/32] Improves response time by keep the buffer open --- klippy/extras/framebuffer_display.py | 171 +++++++++++++++++---------- 1 file changed, 109 insertions(+), 62 deletions(-) diff --git a/klippy/extras/framebuffer_display.py b/klippy/extras/framebuffer_display.py index f061f19f8..a20e4b028 100644 --- a/klippy/extras/framebuffer_display.py +++ b/klippy/extras/framebuffer_display.py @@ -4,7 +4,6 @@ # # This file may be distributed under the terms of the GNU GPLv3 license. # -import io import logging import math import re @@ -15,7 +14,7 @@ from PIL import Image import threading -class FramebufferDisplay(): +class FramebufferDisplay: def __init__(self, config): self.printer = config.get_printer() @@ -24,16 +23,21 @@ class FramebufferDisplay(): self.type = config.getchoice('type', atypes, default='Mono') self.framebuffer_index = config.getint('framebuffer_index', minval=0) if not os.path.exists(self.get_framebuffer_path()): - msg = f"The frame buffer device {self.get_framebuffer_path()} does not exists." + msg = ("The frame buffer device %s does not exists." + % self.get_framebuffer_path()) logging.exception(msg) raise config.error(msg) self.resolution_x = config.getint('resolution_x', minval=1) # In pixels self.resolution_y = config.getint('resolution_y', minval=1) # In pixels - self.display_width = config.getfloat('display_width', 0., above=0.) # In millimeters - self.display_height = config.getfloat('display_height', 0., above=0.) # In millimeters - self.pixel_width = config.getfloat('pixel_width', 0., above=0.) # In millimeters - self.pixel_height = config.getfloat('pixel_height', 0., above=0.) # In millimeters + self.display_width = config.getfloat('display_width', 0., + above=0.) # In millimeters + self.display_height = config.getfloat('display_height', 0., + above=0.) # In millimeters + self.pixel_width = config.getfloat('pixel_width', 0., + above=0.) # In millimeters + self.pixel_height = config.getfloat('pixel_height', 0., + above=0.) # In millimeters if self.display_width == 0. and self.pixel_width == 0.: msg = "Either display_width or pixel_width must be provided." @@ -51,59 +55,81 @@ class FramebufferDisplay(): self.pixel_width = round(self.display_width / self.resolution_x, 5) if self.display_height == 0.: - self.display_height = round(self.resolution_y * self.pixel_height, 4) + self.display_height = round(self.resolution_y * self.pixel_height, + 4) elif self.pixel_height == 0.: - self.pixel_height = round(self.display_height / self.resolution_y, 5) + self.pixel_height = round(self.display_height / self.resolution_y, + 5) # Calculated values self.bit_depth = 8 if self.type == 'Mono' else 24 self.is_landscape = self.resolution_x >= self.resolution_y self.is_portrait = not self.is_landscape - self.diagonal_inch = round(math.sqrt(self.display_width ** 2 + self.display_height ** 2) / 25.4, 2) + self.diagonal_inch = round(math.sqrt( + self.display_width ** 2 + self.display_height ** 2) / 25.4, 2) # get modes width and height - with open(f"/sys/class/graphics/fb{self.framebuffer_index}/modes", "r") as f: + with open(f"/sys/class/graphics/fb{self.framebuffer_index}/modes", + "r") as f: modes = f.read() match = re.search(r"(\d+)x(\d+)", modes) if match: self.fb_modes_width = int(match.group(1)) self.fb_modes_height = int(match.group(2)) else: - msg = f"Unable to extract \"modes\" information from fame buffer device fb{self.framebuffer_index}." + msg = ('Unable to extract "modes" information from famebuffer ' + f"device fb{self.framebuffer_index}.") logging.exception(msg) raise config.error(msg) # get virtual width and height - with open(f"/sys/class/graphics/fb{self.framebuffer_index}/virtual_size", "r") as f: + with open( + f"/sys/class/graphics/fb{self.framebuffer_index}/virtual_size", + "r") as f: virtual_size = f.read() width_string, height_string = virtual_size.split(',') self.fb_virtual_width = int(width_string) # width self.fb_virtual_height = int(height_string) # height # get stride - with open(f"/sys/class/graphics/fb{self.framebuffer_index}/stride", "r") as f: + with open(f"/sys/class/graphics/fb{self.framebuffer_index}/stride", + "r") as f: self.fb_stride = int(f.read()) # get bits per pixel - with open(f"/sys/class/graphics/fb{self.framebuffer_index}/bits_per_pixel", "r") as f: + with open( + f"/sys/class/graphics/fb{self.framebuffer_index}/bits_per_pixel", + "r") as f: self.fb_bits_per_pixel = int(f.read()) # Check if configured resolutions match framebuffer information if self.resolution_x not in (self.fb_stride, self.fb_modes_width): - msg = f"The configured resolution_x of {self.resolution_x} does not match any of the framebuffer stride of ({self.fb_stride}) nor the modes of ({self.fb_modes_width})." + msg = (f"The configured resolution_x of {self.resolution_x} " + f"does not match any of the framebuffer stride " + f"of ({self.fb_stride}) nor " + f"the modes of ({self.fb_modes_width}).") logging.exception(msg) raise config.error(msg) if self.resolution_y != self.fb_modes_height: - msg = f"The configured resolution_y of {self.resolution_y} does not match the framebuffer modes of ({self.fb_modes_height})." + msg = (f"The configured resolution_y of {self.resolution_y} does " + f"not match the framebuffer " + f"modes of ({self.fb_modes_height}).") logging.exception(msg) raise config.error(msg) self.fb_maxsize = self.fb_stride * self.fb_modes_height - # Defines the current thread that hold the display session to the framebuffer + # Defines the current thread that hold the display session to the fb self._framebuffer_thread = None self.is_busy = False + # Keeps device open to spare transfer times + self._fb_device = open(self.get_framebuffer_path(), mode='r+b') + self.fb_memory_map = mmap.mmap(self._fb_device.fileno(), + self.fb_stride * self.fb_modes_height, + mmap.MAP_SHARED, + mmap.PROT_WRITE | mmap.PROT_READ) + self.clear_framebuffer_threaded() def get_framebuffer_path(self) -> str: @@ -113,18 +139,24 @@ class FramebufferDisplay(): """ return f"/dev/fb{self.framebuffer_index}" - def wait_framebuffer_thread(self, timeout=None): + def wait_framebuffer_thread(self, timeout=None) -> bool: """ Wait for the framebuffer thread to terminate (Finish writes). - @param timeout: Timeout time to wait for framebuffer thread to terminate. + @param timeout: Timeout time to wait for framebuffer thread to terminate + @return: True if waited for framebuffer thread, otherwise False. """ - if self._framebuffer_thread is not None and self._framebuffer_thread.is_alive(): - self._framebuffer_thread.join(timeout) # Wait for other render completion + if (self._framebuffer_thread is not None + and self._framebuffer_thread.is_alive()): + # Wait for other render completion + self._framebuffer_thread.join(timeout) + return True + return False def get_image_buffer(self, path) -> bytes: """ Reads an image from disk and return it buffer, ready to send. - Note that it does not do any file check, it will always try to open an image file. + Note that it does not do any file check, + it will always try to open an image file. @param path: Image path @return: bytes buffer """ @@ -136,11 +168,10 @@ class FramebufferDisplay(): Clears the display with zeros (black background). """ self.is_busy = True - with open(self.get_framebuffer_path(), mode='r+b') as framebuffer_device: # open R/W - with mmap.mmap(framebuffer_device.fileno(), self.fb_stride * self.fb_modes_height, mmap.MAP_SHARED, - mmap.PROT_WRITE | mmap.PROT_READ) as framebuffer_memory_map: - color = 0 - framebuffer_memory_map.write(color.to_bytes(1, byteorder='little') * self.fb_maxsize) + color = 0 + self.fb_memory_map.write(color.to_bytes(1, byteorder='little') + * self.fb_maxsize) + self.fb_memory_map.seek(0) self.is_busy = False def clear_framebuffer_threaded(self, wait_thread=False): @@ -149,8 +180,9 @@ class FramebufferDisplay(): @param wait_thread: Wait for the framebuffer thread to terminate. """ self.wait_framebuffer_thread() - self._framebuffer_thread = threading.Thread(target=self.clear_framebuffer, - name=f"Clears the fb{self.framebuffer_index}") + self._framebuffer_thread = threading.Thread( + target=self.clear_framebuffer, + name=f"Clears the fb{self.framebuffer_index}") self._framebuffer_thread.start() if wait_thread: self._framebuffer_thread.join() @@ -159,14 +191,16 @@ class FramebufferDisplay(): """ Send a byte array to the framebuffer device. @param buffer: A 1D byte array with data to send - @param clear_first: If true first clears the display and then show the image. + @param clear_first: If true first clears the display and then, + renders the image. @param offset: Sets a positive offset from start position of the buffer. """ if buffer is None or len(buffer) == 0: return if not isinstance(buffer, (list, bytes)): - msg = f"The buffer must be a 1D byte array. {type(buffer)} was passed" + msg = (f"The buffer must be a 1D byte array. " + f"{type(buffer)} was passed") logging.exception(msg) raise Exception(msg) @@ -177,45 +211,50 @@ class FramebufferDisplay(): # open framebuffer and map it onto a python bytearray self.is_busy = True - with open(self.get_framebuffer_path(), mode='r+b') as framebuffer_device: # open R/W - with mmap.mmap(framebuffer_device.fileno(), self.fb_stride * self.fb_modes_height, mmap.MAP_SHARED, - mmap.PROT_WRITE | mmap.PROT_READ) as framebuffer_memory_map: - if clear_first: - color = 0 - framebuffer_memory_map.write(color.to_bytes(1, byteorder='little') * self.fb_maxsize) - buffer_size = len(buffer) - if buffer_size + offset > self.fb_maxsize: - msg = f"The buffer size of {buffer_size} + {offset} is larger than actual framebuffer size of {self.fb_maxsize}." - logging.exception(msg) - raise Exception(msg) + if clear_first: + color = 0 + self.fb_memory_map.write( + color.to_bytes(1, byteorder='little') * self.fb_maxsize) - if offset > 0: - framebuffer_memory_map.seek(offset) + buffer_size = len(buffer) + if buffer_size + offset > self.fb_maxsize: + msg = (f"The buffer size of {buffer_size} + {offset} is larger " + f"than actual framebuffer size of {self.fb_maxsize}.") + logging.exception(msg) + raise Exception(msg) - framebuffer_memory_map.write(buffer) + if offset > 0: + self.fb_memory_map.seek(offset) + + self.fb_memory_map.write(buffer) + self.fb_memory_map.seek(0) self.is_busy = False - def send_buffer_threaded(self, buffer, clear_first=False, offset=0, wait_thread=False): + def send_buffer_threaded(self, buffer, clear_first=False, offset=0, + wait_thread=False): """ Send a byte array to the framebuffer device in a separate thread. @param buffer: A 1D byte array with data to send - @param clear_first: If true first clears the display and then show the image. + @param clear_first: If true first clears the display + and then renders the image. @param offset: Sets a positive offset from start position of the buffer. @param wait_thread: If true wait for the framebuffer thread to terminate """ self.wait_framebuffer_thread() - self._framebuffer_thread = threading.Thread(target=lambda: self.send_buffer(buffer, clear_first, offset), - name=f"Sends an buffer to fb{self.framebuffer_index}") + self._framebuffer_thread = threading.Thread( + target=lambda: self.send_buffer(buffer, clear_first, offset), + name=f"Sends an buffer to fb{self.framebuffer_index}") self._framebuffer_thread.start() if wait_thread: self._framebuffer_thread.join() def send_image(self, path, clear_first=False, offset=0): """ - Reads an image from a path and sends the bitmap to the framebuffer device. + Reads an image from a path and sends the bitmap to the fb device. @param path: Image file path to be read. - @param clear_first: If true first clears the display and then show the image. + @param clear_first: If true first clears the display and + then renders the image. @param offset: Sets a positive offset from start position of the buffer. """ if offset < 0: @@ -237,17 +276,20 @@ class FramebufferDisplay(): buffer = self.get_image_buffer(path) self.send_buffer(buffer, clear_first, offset) - def send_image_threaded(self, path, clear_first=False, offset=0, wait_thread=False): + def send_image_threaded(self, path, clear_first=False, offset=0, + wait_thread=False): """ - Reads an image from a path and sends the bitmap to the framebuffer device. + Reads an image from a path and sends the bitmap to the fb device. @param path: Image file path to be read. - @param clear_first: If true first clears the display and then show the image. + @param clear_first: If true first clears the display + and then renders the image. @param offset: Sets a positive offset from start position of the buffer. - @param wait_thread: If true wait for the framebuffer thread to terminate. + @param wait_thread: If true wait for the framebuffer thread to terminate """ self.wait_framebuffer_thread() - self._framebuffer_thread = threading.Thread(target=lambda: self.send_image(path, clear_first, offset), - name=f"Render an image to fb{self.framebuffer_index}") + self._framebuffer_thread = threading.Thread( + target=lambda: self.send_image(path, clear_first, offset), + name=f"Render an image to fb{self.framebuffer_index}") self._framebuffer_thread.start() if wait_thread: self._framebuffer_thread.join() @@ -259,19 +301,24 @@ class FramebufferDisplayWrapper(FramebufferDisplay): device_name = config.get_name().split()[1] gcode = self.printer.lookup_object('gcode') - gcode.register_mux_command("FRAMEBUFFER_CLEAR", "DEVICE", device_name, self.cmd_FRAMEBUFFER_CLEAR, + gcode.register_mux_command("FRAMEBUFFER_CLEAR", "DEVICE", device_name, + self.cmd_FRAMEBUFFER_CLEAR, desc=self.cmd_FRAMEBUFFER_CLEAR_help) - gcode.register_mux_command("FRAMEBUFFER_SEND_IMAGE", "DEVICE", device_name, self.cmd_FRAMEBUFFER_SEND_IMAGE, + gcode.register_mux_command("FRAMEBUFFER_SEND_IMAGE", "DEVICE", + device_name, self.cmd_FRAMEBUFFER_SEND_IMAGE, desc=self.cmd_FRAMEBUFFER_SEND_IMAGE_help) + cmd_FRAMEBUFFER_CLEAR_help = ("Clears the display with zeros " + "(black background)") - cmd_FRAMEBUFFER_CLEAR_help = "Clears the display with zeros (black background)" def cmd_FRAMEBUFFER_CLEAR(self, gcmd): wait_thread = gcmd.get_int('WAIT', 0, 0, 1) self.clear_framebuffer_threaded(wait_thread) - cmd_FRAMEBUFFER_SEND_IMAGE_help = "Send a image from a path and display it on the framebuffer." + cmd_FRAMEBUFFER_SEND_IMAGE_help = ("Send a image from a path " + "and render it on the framebuffer.") + def cmd_FRAMEBUFFER_SEND_IMAGE(self, gcmd): path = gcmd.get('PATH') offset = gcmd.get_int('OFFSET', 0, 0) From e5c702f3e0ebd665b63efb50f2deb64790ce1234 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Concei=C3=A7=C3=A3o?= Date: Thu, 25 Apr 2024 23:59:24 +0100 Subject: [PATCH 10/32] Improvements to mSLA system - Sync display with UV LED in two-way mode - Add a response test to measure the UV LED shutter time - Allow to define the response delay of the UV LED shutter and take it into account when using M1400 Px - Fix regex for the file path parse from M6054 - Refactorings --- klippy/extras/msla_display.py | 339 ++++++++++++++++++++++++---------- 1 file changed, 241 insertions(+), 98 deletions(-) diff --git a/klippy/extras/msla_display.py b/klippy/extras/msla_display.py index cef62e616..04d54f9ab 100644 --- a/klippy/extras/msla_display.py +++ b/klippy/extras/msla_display.py @@ -7,15 +7,15 @@ import logging import os -import re import threading +import re import time import tempfile from pathlib import Path from PIL import Image -from . import framebuffer_display +from . import framebuffer_display, pwm_tool, output_pin -CACHE_MIN_FREE_RAM = 256 # 256 MB +CACHE_MIN_RAM = 256 # 256 MB class mSLADisplay(framebuffer_display.FramebufferDisplay): @@ -25,6 +25,8 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): def __init__(self, config): super().__init__(config) + + # CACHE CONFIG self.buffer_cache_count = config.getint('cache', 0, 0, 100) # RAM guard @@ -34,12 +36,16 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): sline = i.split() if str(sline[0]) == 'MemFree:': free_memory_mb = int(sline[1]) / 1024. - buffer_max_size = (self.buffer_cache_count + 1) * self.fb_maxsize / 1024. / 1024. - if free_memory_mb - buffer_max_size < CACHE_MIN_FREE_RAM: - msg = ("The current cache count of %d requires %.2f MB of RAM, currently have %.2f MB of " - "free memory. It requires at least a margin of %.2f MB of free RAM.\n" + buffer_max_size = (self.buffer_cache_count + 1 + ) * self.fb_maxsize / 1024. / 1024. + if free_memory_mb - buffer_max_size < CACHE_MIN_RAM: + msg = ("The current cache count of %d requires %.2f" + " MB of RAM, currently have %.2f MB of free " + "memory. It requires at least a margin of " + "%.2f MB of free RAM.\n" "Please reduce the cache count.") % ( - self.buffer_cache_count, buffer_max_size, free_memory_mb, CACHE_MIN_FREE_RAM) + self.buffer_cache_count, buffer_max_size, + free_memory_mb, CACHE_MIN_RAM) logging.exception(msg) raise config.error(msg) break @@ -47,31 +53,58 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): self._cache = [] self._cache_thread = None - self.uvled_output_pin_name = config.get('uvled_output_pin_name', 'msla_uvled') - self.uvled = self.printer.lookup_object(f"output_pin {self.uvled_output_pin_name}") + # UVLED CONFIG + self.uvled_output_pin_name = config.get('uvled_output_pin_name', + 'msla_uvled') + self.uvled_response_delay = config.getfloat('uvled_response_delay', 0, + minval=0., maxval=1000.) + self.uvled = None + try: + self.uvled = self.printer.lookup_object("output_pin %s" % + self.uvled_output_pin_name) + except: + pass + + try: + self.uvled = self.printer.lookup_object("pwm_tool %s" % + self.uvled_output_pin_name) + except: + pass + + if self.uvled is None: + msg = (f"The [output_pin %s] or [pwm_tool %s] was not found." % + (self.uvled_output_pin_name, self.uvled_output_pin_name)) + logging.exception(msg) + raise config.error(msg) + + # Variables self._sd = self.printer.lookup_object("virtual_sdcard") # Events - self.printer.register_event_handler("virtual_sdcard:reset_file", self.clear_cache) + self.printer.register_event_handler("virtual_sdcard:reset_file", + self.clear_cache) # Register commands gcode = self.printer.lookup_object('gcode') - gcode.register_command("MSLA_DISPLAY_VALIDATE", self.cmd_MSLA_DISPLAY_VALIDATE, + gcode.register_command("MSLA_DISPLAY_VALIDATE", + self.cmd_MSLA_DISPLAY_VALIDATE, desc=self.cmd_MSLA_DISPLAY_VALIDATE_help) - gcode.register_command("MSLA_DISPLAY_RESPONSE_TIME", self.cmd_MSLA_DISPLAY_RESPONSE_TIME, + gcode.register_command("MSLA_DISPLAY_RESPONSE_TIME", + self.cmd_MSLA_DISPLAY_RESPONSE_TIME, desc=self.cmd_MSLA_DISPLAY_RESPONSE_TIME_help) + gcode.register_command('M6054', self.cmd_M6054, + desc=self.cmd_M6054_help) - #gcode.register_command("MSLA_DISPLAY_CLEAR", self.cmd_MSLA_DISPLAY_CLEAR, - # desc=self.cmd_MSLA_DISPLAY_CLEAR_help) - #gcode.register_command("MSLA_DISPLAY_IMAGE", self.cmd_MSLA_DISPLAY_IMAGE, - # desc=self.cmd_MSLA_DISPLAY_IMAGE_help) - - gcode.register_command('M6054', self.cmd_M6054, desc=self.cmd_M6054_help) - gcode.register_command('M1400', self.cmd_M1400, desc=self.cmd_M1400_help) + gcode.register_command("MSLA_UVLED_RESPONSE_TIME", + self.cmd_MSLA_UVLED_RESPONSE_TIME, + desc=self.cmd_MSLA_UVLED_RESPONSE_TIME_help) + gcode.register_command('M1400', self.cmd_M1400, + desc=self.cmd_M1400_help) def get_status(self, eventtime): return {'display_busy': self.is_busy, + 'is_exposure': self.is_exposure(), 'uvled_value': self.uvled.last_value * 255.} def clear_cache(self): @@ -85,7 +118,9 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): Returns true if is ready to cache @rtype: bool """ - return self.buffer_cache_count > 0 and (self._cache_thread is None or not self._cache_thread.is_alive()) + return (self.buffer_cache_count > 0 + and (self._cache_thread is None + or not self._cache_thread.is_alive())) def _wait_cache_thread(self) -> bool: """ @@ -123,7 +158,7 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): def _get_cache_index(self, path) -> int: """ - Gets the index of the cache position on the list given a path + Gets the index of the cache position on the list given an path @param path: Path to seek @return: Index of the cache on the list, otherwise -1 """ @@ -134,7 +169,8 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): def _get_image_buffercache(self, gcmd, path) -> bytes: """ - Gets a image buffer from cache if available, otherwise it creates the buffer from the path image + Gets a image buffer from cache if available, otherwise it creates the + buffer from the path image @param gcmd: @param path: Image path @return: Image buffer @@ -147,16 +183,16 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): raise gcmd.error(f"M6054: The file '{path}' does not exists.") if self.buffer_cache_count > 0: - self._wait_cache_thread() # May be worth to wait if streaming images + self._wait_cache_thread() # May be worth to wait if streaming i = self._get_cache_index(path) - #gcmd.respond_raw(f"File in cache: {i}, cache size: {len(self._cache)}, Can cache: {self._can_cache()}") if i >= 0: cache = self._cache[i] # RTrim up to cache self._cache = self._cache[i + 1:] if self._can_cache(): - self._cache_thread = threading.Thread(target=self._process_cache, args=(cache,)) + self._cache_thread = threading.Thread( + target=self._process_cache, args=(cache,)) self._cache_thread.start() return cache.buffer @@ -167,19 +203,22 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): buffer = self.get_image_buffer(path) if current_filepath and self._can_cache(): cache = BufferCache(path, buffer) - self._cache_thread = threading.Thread(target=self._process_cache, args=(cache,)) + self._cache_thread = threading.Thread(target=self._process_cache, + args=(cache,)) self._cache_thread.start() return buffer - cmd_MSLA_DISPLAY_VALIDATE_help = "Validate the display against resolution and pixel parameters. Throw error if out of parameters." - + cmd_MSLA_DISPLAY_VALIDATE_help = ("Validate the display against resolution " + "and pixel parameters. Throw error if out" + " of parameters.") def cmd_MSLA_DISPLAY_VALIDATE(self, gcmd): """ - Layer images are universal within same resolution and pixel pitch. Other printers with same resolution and pixel - pitch can print same file. This command ensure print only continue if requirements are meet. + Layer images are universal within same resolution and pixel pitch. + Other printers with same resolution and pixel pitch can print same file. + This command ensure print only continue if requirements are meet. - Syntax: MSLA_DISPLAY_VALIDATE RESOLUTION= PIXEL= + Syntax: MSLA_DISPLAY_VALIDATE RESOLUTION= PIXEL= RESOLUTION: Machine LCD resolution PIXEL: Machine LCD pixel pitch @@ -191,7 +230,8 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): resolution_split = resolution.split(',', 1) if len(resolution_split) < 2: - raise gcmd.error(f"The resolution of {resolution} is malformed, format: RESOLUTION_X,RESOLUTION_Y.") + raise gcmd.error(f"The resolution of {resolution} is malformed. " + f"Format: RESOLUTION_X,RESOLUTION_Y.") try: resolution_x = int(resolution_split[0]) @@ -204,36 +244,46 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): raise gcmd.error(f"The resolution y must be an integer.") if resolution_x != self.resolution_x: - raise gcmd.error(f"The resolution X of {resolution_x} is invalid. Should be {self.resolution_x}.") + raise gcmd.error(f"The resolution X of {resolution_x} is invalid. " + f"Should be {self.resolution_x}.") if resolution_y != self.resolution_y: - raise gcmd.error(f"The resolution Y of {resolution_y} is invalid. Should be {self.resolution_y}.") + raise gcmd.error(f"The resolution Y of {resolution_y} is invalid. " + f"Should be {self.resolution_y}.") pixel_size_split = pixel_size.split(',', 1) if len(pixel_size_split) < 2: - raise gcmd.error(f"The pixel size of {pixel_size} is malformed, format: PIXEL_WIDTH,PIXEL_HEIGHT.") + raise gcmd.error(f"The pixel size of {pixel_size} is malformed. " + f"Format: PIXEL_WIDTH,PIXEL_HEIGHT.") try: pixel_width = float(pixel_size_split[0]) except: - raise gcmd.error(f"The pixel width must be an floating point number.") + raise gcmd.error(f"The pixel width must be an floating point " + f"number.") try: pixel_height = float(pixel_size_split[1]) except: - raise gcmd.error(f"The pixel height must be an floating point number.") + raise gcmd.error(f"The pixel height must be an floating point " + f"number.") if pixel_width != self.pixel_width: - raise gcmd.error(f"The pixel width of {pixel_width} is invalid. Should be {self.pixel_width}.") + raise gcmd.error(f"The pixel width of {pixel_width} is invalid. " + f"Should be {self.pixel_width}.") if pixel_height != self.pixel_height: - raise gcmd.error(f"The pixel height of {pixel_height} is invalid. Should be {self.pixel_height}.") + raise gcmd.error(f"The pixel height of {pixel_height} is invalid. " + f"Should be {self.pixel_height}.") - cmd_MSLA_DISPLAY_RESPONSE_TIME_help = "Send a buffer to display and test it response time to fill that buffer." + cmd_MSLA_DISPLAY_RESPONSE_TIME_help = ("Sends a buffer to display and test " + "it response time to complete the " + "render.") def cmd_MSLA_DISPLAY_RESPONSE_TIME(self, gcmd): """ - Tests the display response time + Sends a buffer to display and test it response time + to complete the render. Syntax: MSLA_DISPLAY_RESPONSE_TIME AVG=[x] @@ -243,19 +293,23 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): """ avg = gcmd.get_int('AVG', 1, 1, 20) - gcmd.respond_raw( - 'Buffer size: %d bytes (%.2f MB)' % (self.fb_maxsize, round(self.fb_maxsize / 1024.0 / 1024.0, 2))) + fb_maxsize_mb = round(self.fb_maxsize / 1024.0 / 1024.0, 2) + gcmd.respond_raw('Buffer size: %d bytes (%.2f MB)' + % (self.fb_maxsize, fb_maxsize_mb)) time_sum = 0 with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, 'buffer.png') - with Image.new('L', (self.resolution_x, self.resolution_y), color='black') as image: + with Image.new('L', + (self.resolution_x, self.resolution_y), + color='black') as image: image.save(path, "PNG") for _ in range(avg): timems = time.time() self.send_image(path) time_sum += time.time() - timems - gcmd.respond_raw('Read image from disk + send time: %fms (%d samples)' % (round(time_sum * 1000 / avg, 6), avg)) + gcmd.respond_raw('Read image from disk + send time: %fms (%d samples)' + % (round(time_sum * 1000 / avg, 6), avg)) time_sum = 0 buffer = bytes(os.urandom(self.fb_maxsize)) @@ -263,41 +317,24 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): timems = time.time() self.send_buffer(buffer) time_sum += time.time() - timems - gcmd.respond_raw('Send cached buffer: %fms (%d samples)' % (round(time_sum * 1000 / avg, 6), avg)) + gcmd.respond_raw('Send cached buffer: %fms (%d samples)' + % (round(time_sum * 1000 / avg, 6), avg)) time_sum = 0 for _ in range(avg): timems = time.time() self.clear_framebuffer() time_sum += time.time() - timems - gcmd.respond_raw('Clear time: %fms (%d samples)' % (round(time_sum * 1000 / avg, 6), avg)) + gcmd.respond_raw('Clear time: %fms (%d samples)' % + (round(time_sum * 1000 / avg, 6), avg)) - # Merged both into M6054 - # cmd_MSLA_DISPLAY_CLEAR_help = "Clears the display by send a full buffer of zeros." - # - # def cmd_MSLA_DISPLAY_CLEAR(self, gcmd): - # wait_thread = gcmd.get_int('WAIT', 0, 0, 1) - # self.clear_framebuffer_threaded(wait_thread) - # - # cmd_MSLA_DISPLAY_IMAGE_help = "Sends a image from a path and display it on the main display." - # - # def cmd_MSLA_DISPLAY_IMAGE(self, gcmd): - # path = gcmd.get('PATH') - # clear_first = gcmd.get_int('CLEAR', 0, 0, 1) - # offset = gcmd.get_int('OFFSET', 0, 0) - # wait_thread = gcmd.get_int('WAIT', 0, 0, 1) - # path = os.path.normpath(os.path.expanduser(path)) - # - # #times = time.time() - # buffer = self._get_image_buffercache(gcmd, path) - # self.send_buffer_threaded(buffer, clear_first, offset, wait_thread) - # #gcmd.respond_raw("Get/cache buffer: %.2f ms" % ((time.time() - times) * 1000.,)) - - cmd_M6054_help = "Sends a image from a path and display it on the main display. If no parameter it will clear the display" + cmd_M6054_help = ("Reads an image from a path and display it on " + "the display. No parameter clears the display.") def cmd_M6054(self, gcmd): """ - Display an image from a path and display it on the main display. If no parameter it will clear the display + Display an image from a path and display it on the main display. + No parameter clears the display. Syntax: M6054 F<"file.png"> C[0/1] O[x] W[0/1] F: File path eg: "1.png". quotes are optional. (str) @@ -310,50 +347,146 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): offset = gcmd.get_int('O', 0, 0) wait_thread = gcmd.get_int('W', 0, 0, 1) + toolhead = self.printer.lookup_object('toolhead') params = gcmd.get_raw_command_parameters().strip() - - match = re.search(r'F?(.+[.](png|jpg|jpeg|bmp|gif))', params) + match = re.search(r'F(.+[.](png|jpg|jpeg|bmp|gif))', params) if match: path = match.group(1).strip(' "\'') path = os.path.normpath(os.path.expanduser(path)) buffer = self._get_image_buffercache(gcmd, path) - self.send_buffer_threaded(buffer, clear_first, offset, wait_thread) + if self.is_exposure(): + # LED is ON, sync to prevent image change while exposure + toolhead.wait_moves() + self.send_buffer(buffer, clear_first, offset) else: if 'F' in params: - raise gcmd.error(f"M6054: The F parameter is malformed. Use a proper image file.") + raise gcmd.error('M6054: The F parameter is malformed.' + 'Use a proper image file.') + if self.is_exposure(): + toolhead.wait_moves() self.clear_framebuffer_threaded(wait_thread) + def is_exposure(self) -> bool: + """ + True if the UV LED is on, exposure a layer, otherwise False + @return: + """ + return self.uvled.last_value > 0 + + def set_uvled(self, value, delay=0): + """ + Turns the UV LED on or off. + @param value: PWM value from 0 to 255 + @param delay: If set, it will switch off from on state after specified + delay. This also ensures that the UV LED is in sync with + the display. + @return: + """ + if (self.uvled is pwm_tool.PrinterOutputPin or ( + self.uvled is output_pin.PrinterOutputPin + and self.uvled.is_pwm)): + value /= 255.0 + else: + value = 1 if value else 0 + + if value > 0: + # Sync display render + self.wait_framebuffer_thread() + + # Do not continue if same value + if self.uvled.last_value == value: + return + + toolhead = self.printer.lookup_object('toolhead') + toolhead.register_lookahead_callback( + lambda print_time: self.uvled._set_pin(print_time, value)) + + if value > 0 and delay > 0: + delay -= self.uvled_response_delay / 1000. + if delay > 0: + toolhead.dwell(delay) + toolhead.register_lookahead_callback( + lambda print_time: self.uvled._set_pin(print_time, 0)) + toolhead.wait_moves() + + cmd_MSLA_UVLED_RESPONSE_TIME_help = ("Tests the response time for the " + "UV LED shutter.") + + def cmd_MSLA_UVLED_RESPONSE_TIME(self, gcmd): + """ + Tests the response time for the UV LED to turn on and off. + + Syntax: MSLA_UVLED_RESPONSE_TIME TIME= OFFSET=[ms] + TIME: Exposure time in milliseconds. (int) + OFFSET: Offset time from exposure time. (int) + TYPE: <0> when using M1400 Sx and Px. (Faster response time) + <1> when using M1400 Sx, G4 Px, M1400 S0 (Slower response time) + @param gcmd: + @return: + """ + self.set_uvled(0) + self.clear_framebuffer() + + delay = gcmd.get_int('TIME', 3000, minval=500, maxval=5000) / 1000. + offset = gcmd.get_int('OFFSET', 0, minval=-1000, maxval=0) / 1000. + type = gcmd.get_int('TYPE', 0, minval=0, maxval=1) + + if type == 0: + offset -= 0.2 + + toolhead = self.printer.lookup_object('toolhead') + + toolhead.wait_moves() + time_total = enable_time = time.time() + toolhead.register_lookahead_callback( + lambda print_time: self.uvled._set_pin(print_time, 1)) + toolhead.wait_moves() + enable_time = round(time.time() - enable_time, 4) + + exposure_time = time.time() + toolhead.dwell(delay + offset) + + toolhead.register_lookahead_callback( + lambda print_time: self.uvled._set_pin(print_time, 0)) + toolhead.wait_moves() + + final_time = time.time() + exposure_time = round(final_time - exposure_time, 4) + time_total = round(final_time - time_total, 4) + + if type == 0: + offset += 0.2 + calculated_offset = max(0, + round((exposure_time - delay + offset) + * 1000)) + + exposure_factor = round(exposure_time / delay, 2) + + gcmd.respond_raw(f"UV LED RESPONSE TIME TEST ({delay}s):\n" + f"Switch time: {round(enable_time * 1000)} ms\n" + f"Exposure time: {exposure_time} s\n" + f"Total time: {time_total} s\n" + f"Exposure time factor: {exposure_factor} x\n" + f"Calculated delay offset: {calculated_offset} ms\n" + ) + cmd_M1400_help = "Turn the main UV LED to cure the pixels." def cmd_M1400(self, gcmd): """ Turn the main UV LED to cure the pixels. - M1400 comes from the wavelength of UV radiation (UVR) lies in the range of 100–400 nm, - and is further subdivided into UVA (315–400 nm), UVB (280–315 nm), and UVC (100–280 nm). + M1400 comes from the wavelength of UV radiation (UVR) + lies in the range of 100–400 nm, and is further subdivided into + UVA (315–400 nm), UVB (280–315 nm), and UVC (100–280 nm). - Syntax: M1400 S<0-255> P[milliseconds] + Syntax: M1400 S<0-255> P[ms] S: LED Power (Non PWM LEDs will turn on from 1 to 255). (int) P: Time to wait in milliseconds when (S>0) to turn off. (int) @param gcmd: """ value = gcmd.get_float('S', minval=0., maxval=255.) delay = gcmd.get_float('P', 0, above=0.) / 1000. - - if self.uvled.is_pwm: - value /= 255.0 - else: - value = 1 if value else 0 - - if value > 0: - # Sync display - self.wait_framebuffer_thread() - - toolhead = self.printer.lookup_object('toolhead') - toolhead.register_lookahead_callback(lambda print_time: self.uvled._set_pin(print_time, value)) - - if value > 0 and delay > 0: - toolhead.dwell(delay) - toolhead.register_lookahead_callback(lambda print_time: self.uvled._set_pin(print_time, 0)) + self.set_uvled(value, delay) class BufferCache(): @@ -362,13 +495,23 @@ class BufferCache(): self.buffer = buffer self.pathinfo = Path(path) - def new_path_layerindex(self, n): - return re.sub(rf"\d+\{self.pathinfo.suffix}$", f"{n}{self.pathinfo.suffix}", self.path) + def new_path_layerindex(self, n) -> str: + """ + Return a new path with same base directory but new layer index + @param n: + @return: + """ + return re.sub(rf"\d+\{self.pathinfo.suffix}$", + f"{n}{self.pathinfo.suffix}", self.path) - def get_layer_index(self): + def get_layer_index(self) -> int: + """ + Gets the layer index from a path + @return: New layer index. -1 if not founded. + """ match = re.search(rf"(\d+)\{self.pathinfo.suffix}$", self.path) if match is None: - return None + return -1 return int(match.group(1)) From a4d0f955668ec9558d494088dba77cc257761c57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Concei=C3=A7=C3=A3o?= Date: Fri, 26 Apr 2024 00:17:50 +0100 Subject: [PATCH 11/32] Fix White space errors --- klippy/extras/framebuffer_display.py | 4 +-- klippy/extras/msla_display.py | 18 ++++------- klippy/extras/virtual_sdcard.py | 47 +++++++++++++++++++--------- klippy/toolhead.py | 9 ++++-- 4 files changed, 46 insertions(+), 32 deletions(-) diff --git a/klippy/extras/framebuffer_display.py b/klippy/extras/framebuffer_display.py index a20e4b028..173934ea8 100644 --- a/klippy/extras/framebuffer_display.py +++ b/klippy/extras/framebuffer_display.py @@ -98,8 +98,8 @@ class FramebufferDisplay: # get bits per pixel with open( - f"/sys/class/graphics/fb{self.framebuffer_index}/bits_per_pixel", - "r") as f: + f"/sys/class/graphics/fb{self.framebuffer_index}/bits_per_pixel" + , "r") as f: self.fb_bits_per_pixel = int(f.read()) # Check if configured resolutions match framebuffer information diff --git a/klippy/extras/msla_display.py b/klippy/extras/msla_display.py index 04d54f9ab..b1ccf8337 100644 --- a/klippy/extras/msla_display.py +++ b/klippy/extras/msla_display.py @@ -58,20 +58,14 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): 'msla_uvled') self.uvled_response_delay = config.getfloat('uvled_response_delay', 0, minval=0., maxval=1000.) - self.uvled = None - try: - self.uvled = self.printer.lookup_object("output_pin %s" % - self.uvled_output_pin_name) - except: - pass - - try: + self.uvled = self.printer.lookup_object("output_pin %s" + % self.uvled_output_pin_name, + None) + if self.uvled is None: self.uvled = self.printer.lookup_object("pwm_tool %s" % - self.uvled_output_pin_name) - except: - pass - + self.uvled_output_pin_name, + None) if self.uvled is None: msg = (f"The [output_pin %s] or [pwm_tool %s] was not found." % (self.uvled_output_pin_name, self.uvled_output_pin_name)) diff --git a/klippy/extras/virtual_sdcard.py b/klippy/extras/virtual_sdcard.py index 8728c4594..c46805341 100644 --- a/klippy/extras/virtual_sdcard.py +++ b/klippy/extras/virtual_sdcard.py @@ -28,12 +28,17 @@ class VirtualSD: sd = config.get('path') self.sdcard_dirname = os.path.normpath(os.path.expanduser(sd)) - archive_temp_path = os.path.join(os.sep, 'tmp', 'klipper-archive-print-contents') + archive_temp_path = os.path.join(os.sep, + 'tmp', + 'klipper-archive-print-contents') if self.sdcard_dirname.count(os.sep) >= 3: - archive_temp_path = os.path.normpath(os.path.expanduser(os.path.join(self.sdcard_dirname, '..', 'tmparchive'))) + archive_temp_path = os.path.normpath( + os.path.expanduser( + os.path.join(self.sdcard_dirname, '..', 'tmparchive'))) archive_temp_path = config.get('archive_temp_path', archive_temp_path) - self.sdcard_archive_temp_dirname = os.path.normpath(os.path.expanduser(archive_temp_path)) + self.sdcard_archive_temp_dirname = os.path.normpath( + os.path.expanduser(archive_temp_path)) self.current_file = None self.file_position = self.file_size = 0 # Print Stat Tracking @@ -107,7 +112,8 @@ class VirtualSD: if ext in VALID_ARCHIVE_EXTS: entries = self.get_archive_files(full_path) - # Support only 1 gcode file as if theres more we unable to guess what to print + # Support only 1 gcode file as if there's more we are + # unable to guess which to print count = 0 for entry in entries: entry_ext = entry[entry.rfind('.') + 1:] @@ -227,7 +233,8 @@ class VirtualSD: ext = fname[fname.rfind('.') + 1:] if ext in VALID_ARCHIVE_EXTS: need_extract = True - hashfile = os.path.join(self.sdcard_archive_temp_dirname, DEFAULT_ARCHIVE_HASH_FILENAME) + hashfile = os.path.join(self.sdcard_archive_temp_dirname, + DEFAULT_ARCHIVE_HASH_FILENAME) gcmd.respond_raw(f"Calculating {fname} hash") hash = self._file_hash(os.path.join(self.sdcard_dirname, fname)) @@ -240,15 +247,18 @@ class VirtualSD: need_extract = False if ext == 'zip': - with zipfile.ZipFile(os.path.join(self.sdcard_dirname, fname), "r") as zip_file: + with zipfile.ZipFile(os.path.join( + self.sdcard_dirname, fname),"r") as zip_file: if need_extract: if os.path.isdir(self.sdcard_archive_temp_dirname): shutil.rmtree(self.sdcard_archive_temp_dirname) gcmd.respond_raw(f"Decompressing {fname}...") timenow = time.time() - zip_file.extractall(self.sdcard_archive_temp_dirname) + zip_file.extractall( + self.sdcard_archive_temp_dirname) timenow = time.time() - timenow - gcmd.respond_raw(f"Decompress done in {timenow:.2f} seconds") + gcmd.respond_raw(f"Decompress done in {timenow:.2f}" + f" seconds") with open(hashfile, 'w') as f: f.write(hash) @@ -256,18 +266,22 @@ class VirtualSD: for entry in entries: entry_ext = entry[entry.rfind('.') + 1:] if entry_ext in VALID_GCODE_EXTS: - fname = os.path.join(os.path.join(self.sdcard_archive_temp_dirname, entry)) + fname = os.path.join(os.path.join( + self.sdcard_archive_temp_dirname, entry)) break elif ext == 'tar': - with tarfile.open(os.path.join(self.sdcard_dirname, fname), "r") as tar_file: + with tarfile.open(os.path.join(self.sdcard_dirname, fname), + "r") as tar_file: if need_extract: if os.path.isdir(self.sdcard_archive_temp_dirname): shutil.rmtree(self.sdcard_archive_temp_dirname) gcmd.respond_raw(f"Decompressing {fname}...") timenow = time.time() - tar_file.extractall(self.sdcard_archive_temp_dirname) + tar_file.extractall( + self.sdcard_archive_temp_dirname) timenow = time.time() - timenow - gcmd.respond_raw(f"Decompress done in {timenow:.2f} seconds") + gcmd.respond_raw(f"Decompress done in {timenow:.2f}" + f" seconds") with open(hashfile, 'w') as f: f.write(hash) @@ -275,7 +289,8 @@ class VirtualSD: for entry in entries: entry_ext = entry[entry.rfind('.') + 1:] if entry_ext in VALID_GCODE_EXTS: - fname = os.path.join(os.path.join(self.sdcard_archive_temp_dirname, entry)) + fname = os.path.join(os.path.join( + self.sdcard_archive_temp_dirname, entry)) break else: fname = os.path.join(self.sdcard_dirname, fname) @@ -321,7 +336,8 @@ class VirtualSD: return self.cmd_from_sd # Background work timer def work_handler(self, eventtime): - logging.info("Starting SD card print (position %d)", self.file_position) + logging.info("Starting SD card print (position %d)", + self.file_position) self.reactor.unregister_timer(self.work_timer) try: self.current_file.seek(self.file_position) @@ -391,7 +407,8 @@ class VirtualSD: return self.reactor.NEVER lines = [] partial_input = "" - logging.info("Exiting SD card print (position %d)", self.file_position) + logging.info("Exiting SD card print (position %d)", + self.file_position) self.work_timer = None self.cmd_from_sd = False if error_message is not None: diff --git a/klippy/toolhead.py b/klippy/toolhead.py index 61ac60d38..75ea6ce8c 100644 --- a/klippy/toolhead.py +++ b/klippy/toolhead.py @@ -216,13 +216,15 @@ class ToolHead: self.commanded_pos = [0., 0., 0., 0.] # The manufacturing process type atypes = {'FDM': 'FDM', 'SLA': 'SLA', 'mSLA': 'mSLA', 'DLP': 'DLP'} - self.manufacturing_process = config.getchoice('manufacturing_process', atypes, default='FDM') + self.manufacturing_process = config.getchoice('manufacturing_process', + atypes, default='FDM') if self.manufacturing_process in ('mSLA', 'DLP'): for s in ('msla_display',): section = self.printer.lookup_object(s, None) if section is None: - msg = "Error: A section with [%s] is required for mSLA/DLP printers." % (s,) + msg = ("Error: A section with [%s] is required " + "for mSLA/DLP printers.") % s logging.exception(msg) raise config.error(msg) @@ -295,7 +297,8 @@ class ToolHead: desc=self.cmd_SET_VELOCITY_LIMIT_help) gcode.register_command('M204', self.cmd_M204) - gcode.register_command('QUERY_MANUFACTORING_PROCESS', self.cmd_QUERY_MANUFACTORING_PROCESS, + gcode.register_command('QUERY_MANUFACTORING_PROCESS', + self.cmd_QUERY_MANUFACTORING_PROCESS, desc="Query manufacturing process") From 6e1b6b46835fe88ecf03dad1533a62be631bc20f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Concei=C3=A7=C3=A3o?= Date: Fri, 26 Apr 2024 00:30:08 +0100 Subject: [PATCH 12/32] Fix unicode error --- klippy/extras/framebuffer_display.py | 2 +- klippy/extras/msla_display.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/klippy/extras/framebuffer_display.py b/klippy/extras/framebuffer_display.py index 173934ea8..75e714300 100644 --- a/klippy/extras/framebuffer_display.py +++ b/klippy/extras/framebuffer_display.py @@ -1,6 +1,6 @@ # mSLA and DLP display properties # -# Copyright (C) 2024 Tiago Conceição +# Copyright (C) 2024 Tiago Conceicao # # This file may be distributed under the terms of the GNU GPLv3 license. # diff --git a/klippy/extras/msla_display.py b/klippy/extras/msla_display.py index b1ccf8337..b80acb567 100644 --- a/klippy/extras/msla_display.py +++ b/klippy/extras/msla_display.py @@ -1,6 +1,6 @@ # mSLA and DLP display properties # -# Copyright (C) 2024 Tiago Conceição +# Copyright (C) 2024 Tiago Conceicao # # This file may be distributed under the terms of the GNU GPLv3 license. # From 82e559ca0980a5ef26ace9fc371f42d26e968cbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Concei=C3=A7=C3=A3o?= Date: Sat, 27 Apr 2024 05:32:02 +0100 Subject: [PATCH 13/32] Improvements to framebuffer_display - Remove display_width and height from configuration to simplify - Fix bit_depth not set when 32 - Rename type to pixel_format --- klippy/extras/framebuffer_display.py | 48 +++++++++++----------------- klippy/extras/msla_display.py | 2 +- 2 files changed, 20 insertions(+), 30 deletions(-) diff --git a/klippy/extras/framebuffer_display.py b/klippy/extras/framebuffer_display.py index 75e714300..d8aa43a16 100644 --- a/klippy/extras/framebuffer_display.py +++ b/klippy/extras/framebuffer_display.py @@ -19,8 +19,14 @@ class FramebufferDisplay: self.printer = config.get_printer() self.model = config.get('model', None) - atypes = {'Mono': 'Mono', 'RGB': 'RGB', 'RGBA': 'RGBA'} - self.type = config.getchoice('type', atypes, default='Mono') + atypes = {'Mono': 'Mono', + 'RGB': 'RGB', + 'RGBA': 'RGBA', + 'BGR': 'BGR', + 'BGRA': 'BGRA' + } + self.pixel_format = config.getchoice('pixel_format', atypes, + default='RGB') self.framebuffer_index = config.getint('framebuffer_index', minval=0) if not os.path.exists(self.get_framebuffer_path()): msg = ("The frame buffer device %s does not exists." @@ -30,39 +36,23 @@ class FramebufferDisplay: self.resolution_x = config.getint('resolution_x', minval=1) # In pixels self.resolution_y = config.getint('resolution_y', minval=1) # In pixels - self.display_width = config.getfloat('display_width', 0., - above=0.) # In millimeters - self.display_height = config.getfloat('display_height', 0., - above=0.) # In millimeters self.pixel_width = config.getfloat('pixel_width', 0., above=0.) # In millimeters self.pixel_height = config.getfloat('pixel_height', 0., above=0.) # In millimeters - if self.display_width == 0. and self.pixel_width == 0.: - msg = "Either display_width or pixel_width must be provided." - logging.exception(msg) - raise config.error(msg) + self.display_width = round(self.resolution_x * self.pixel_width, 4) + self.display_height = round(self.resolution_y * self.pixel_height, 4) - if self.display_height == 0. and self.pixel_height == 0.: - msg = "Either display_height or pixel_height must be provided." - logging.exception(msg) - raise config.error(msg) + if self.pixel_format == 'Mono': + self.bit_depth = 8 + if self.pixel_format == 'RGB' or self.pixel_format == 'BGR': + self.bit_depth = 24 + if self.pixel_format == 'RGBA' or self.pixel_format == 'BGRA': + self.bit_depth = 32 + else: + self.bit_depth = len(self.pixel_format) * 8 - if self.display_width == 0.: - self.display_width = round(self.resolution_x * self.pixel_width, 4) - elif self.pixel_width == 0.: - self.pixel_width = round(self.display_width / self.resolution_x, 5) - - if self.display_height == 0.: - self.display_height = round(self.resolution_y * self.pixel_height, - 4) - elif self.pixel_height == 0.: - self.pixel_height = round(self.display_height / self.resolution_y, - 5) - - # Calculated values - self.bit_depth = 8 if self.type == 'Mono' else 24 self.is_landscape = self.resolution_x >= self.resolution_y self.is_portrait = not self.is_landscape self.diagonal_inch = round(math.sqrt( @@ -321,8 +311,8 @@ class FramebufferDisplayWrapper(FramebufferDisplay): def cmd_FRAMEBUFFER_SEND_IMAGE(self, gcmd): path = gcmd.get('PATH') - offset = gcmd.get_int('OFFSET', 0, 0) clear_first = gcmd.get_int('CLEAR', 0, 0, 1) + offset = gcmd.get_int('OFFSET', 0, 0) wait_thread = gcmd.get_int('WAIT', 0, 0, 1) if not os.path.isfile(path): diff --git a/klippy/extras/msla_display.py b/klippy/extras/msla_display.py index b80acb567..4fcc0e443 100644 --- a/klippy/extras/msla_display.py +++ b/klippy/extras/msla_display.py @@ -475,7 +475,7 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): Syntax: M1400 S<0-255> P[ms] S: LED Power (Non PWM LEDs will turn on from 1 to 255). (int) - P: Time to wait in milliseconds when (S>0) to turn off. (int) + P: Time to wait in milliseconds when (S>0) before turn off. (int) @param gcmd: """ value = gcmd.get_float('S', minval=0., maxval=255.) From 0f2de0758f6c23e430147ff7bb6166543ca78ebb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Concei=C3=A7=C3=A3o?= Date: Sat, 27 Apr 2024 05:32:13 +0100 Subject: [PATCH 14/32] Add documentation --- config/sample_msla_display.cfg | 12 ++++++++ docs/Config_Reference.md | 53 ++++++++++++++++++++++++++++++++++ docs/G-Codes.md | 41 ++++++++++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 config/sample_msla_display.cfg diff --git a/config/sample_msla_display.cfg b/config/sample_msla_display.cfg new file mode 100644 index 000000000..734e3610d --- /dev/null +++ b/config/sample_msla_display.cfg @@ -0,0 +1,12 @@ +[output_pin msla_uvled] +pin: PA5 + +[msla_display] +model: TM089CFSP01 +pixel_format: Mono +framebuffer_index: 0 +resolution_x: 3840 +resolution_y: 2400 +pixel_width: 0.05 +pixel_height: 0.05 +uvled_output_pin_name: msla_uvled \ No newline at end of file diff --git a/docs/Config_Reference.md b/docs/Config_Reference.md index 71cdfed87..f0db7769f 100644 --- a/docs/Config_Reference.md +++ b/docs/Config_Reference.md @@ -4377,6 +4377,59 @@ information on menu attributes available during template rendering. # mode start or end. ``` +### [framebuffer_display] + +Support for a HDMI and DSI display attached to the micro-controller accessible +via the framebuffer devices (/dev/fb*). + +See the [command reference](G-Codes.md#framebuffer_display) for +more information. + +``` +[framebuffer_display my_display] +model: LS055R1SX04 +# The display model, informative only. +pixel_format: RGB +# The display pixel format, possible values: +# Mono, RGB, BGR, RGBA, BGRA +# Default: RGB +framebuffer_index: 0 +# The framebuffer index, this maps to /dev/fb +resolution_x: 1440 +resolution_y: 2560 +# The display resolution, these values are compared and validated against the +# the framebuffer device information. +pixel_width: 0.01575 +pixel_height: 0.04725 +# The pixel pitch/size in millimeters, these values are used to calculate +# the display size accordingly. +``` + +### [msla_display] + +Support for a mSLA display that will be used to display images and cure resin +by lit a UV LED under the display pixels. +For the complete configuration follow the +[framebuffer_display](Config_Reference.md#framebuffer_display) + +See the [command reference](G-Codes.md#msla_display) for more information. + +``` +[msla_display] +# Complete with framebuffer_display config in addition to: +buffer_cache_count: 1 +# Number of images to cache while printing, cached buffers cut the processing +# time and render the image faster into the display. Default: 1 +uvled_output_pin_name: msla_uvled +# The configurated UV LED [output_pin msla_uvled] or [pwm_tool msla_uvled], +# either section must exist and be configured to attach a UV LED to this +# display. Default: msla_uvled +uvled_response_delay: 0 +# The UV LED response delay in milliseconds, often this is the time the +# micro-controller takes to switch the UV LED pin. This offset will subtract +# to a defined exposure time when issue the M1400 Sx Px command. Default: 0 +``` + ## Filament sensors ### [filament_switch_sensor] diff --git a/docs/G-Codes.md b/docs/G-Codes.md index e55fba35d..43f5e54dc 100644 --- a/docs/G-Codes.md +++ b/docs/G-Codes.md @@ -322,6 +322,47 @@ Also provided is the following extended G-Code command: setting the supplied `MSG` as the current display message. If `MSG` is omitted the display will be cleared. +### [framebuffer_display] + +The following commands are available when a +[framebuffer_display config section](Config_Reference.md#framebuffer_display) +is enabled. + +- Clear the framebuffer (fill with zeros): + `FRAMEBUFFER_CLEAR DEVICE= WAIT=[0/1]` +- Send image: `FRAMEBUFFER_SEND_IMAGE DEVICE= PATH= CLEAR=[0/1] + OFFSET=[n] WAIT=[0/1]` + - `DEVICE`: Configured device name in [framebuffer_display name] + - `PATH`: Absolute image path + - `CLEAR`: Clear the buffer before send the image + - `OFFSET`: Offset from buffer start to write the image + - `WAIT`: Wait for the render to complete + +### [msla_display] + +The following commands are available when a +[msla_display config section](Config_Reference.md#msla_display) +is enabled. + +- Validate the display information: + `MSLA_DISPLAY_VALIDATE RESOLUTION= PIXEL=` +- Tests the display response time: `MSLA_DISPLAY_RESPONSE_TIME AVG=[1]` + - `AVG`: Number of samples to average the results +- Display image: `M6054 F<"file"> C[0/1] W[0/1]` + - `F`: Image file to display, when printing this file is relative to the + base path of the print gcode + - `C`: Clear the buffer before send the image + - `W`: Wait for the render to complete +- Tests the UV LED response time: + `MSLA_UVLED_RESPONSE_TIME TIME=[ms] OFFSET=[ms] TYPE=[0/1]` + - `TIME`: Exposure time in milliseconds + - `OFFSET`: Offset time from exposure time + - `TYPE`: <0> when using M1400 Sx and Px. (Faster response time). + <1> when using M1400 Sx, G4 Px, M1400 S0 (Slower response time) +- Set the UV LED power: `M1400 S<0-255> P[ms]` + - `S`: The LED Power (Non PWM LEDs will turn on from 1 to 255). + - `P`: Time to wait in milliseconds when (S>0) before turn off. + ### [dual_carriage] The following command is available when the From 78853046ce7914cd2343bbf8e13bcf93bf4f70d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Concei=C3=A7=C3=A3o?= Date: Sat, 27 Apr 2024 19:58:53 +0100 Subject: [PATCH 15/32] Overall improvements - Use locks on framebuffer - Add more utility functions - Add `MSLA_DISPLAY_TEST` command --- docs/G-Codes.md | 10 ++- klippy/extras/framebuffer_display.py | 99 +++++++++++++++++++--------- klippy/extras/msla_display.py | 72 ++++++++++++++++++-- 3 files changed, 140 insertions(+), 41 deletions(-) diff --git a/docs/G-Codes.md b/docs/G-Codes.md index 43f5e54dc..638c7e72d 100644 --- a/docs/G-Codes.md +++ b/docs/G-Codes.md @@ -346,14 +346,20 @@ is enabled. - Validate the display information: `MSLA_DISPLAY_VALIDATE RESOLUTION= PIXEL=` + - `RESOLUTION:` Resolution size in pixels + - `PIXEL`: Pixel size in millimeters - Tests the display response time: `MSLA_DISPLAY_RESPONSE_TIME AVG=[1]` - `AVG`: Number of samples to average the results -- Display image: `M6054 F<"file"> C[0/1] W[0/1]` +- Test the display by showing full white and grey shades: `MSLA_DISPLAY_TEST + DELAY=[ms]` + - `DELAY`: Time in milliseconds between tests +- Display image: `M6054 F<"image.png"> C[0/1] W[0/1]` - `F`: Image file to display, when printing this file is relative to the base path of the print gcode - `C`: Clear the buffer before send the image - `W`: Wait for the render to complete -- Tests the UV LED response time: +- Display clear: `M6054` +- Tests the UV LED response time: `MSLA_UVLED_RESPONSE_TIME TIME=[ms] OFFSET=[ms] TYPE=[0/1]` - `TIME`: Exposure time in milliseconds - `OFFSET`: Offset time from exposure time diff --git a/klippy/extras/framebuffer_display.py b/klippy/extras/framebuffer_display.py index d8aa43a16..7f1080d6a 100644 --- a/klippy/extras/framebuffer_display.py +++ b/klippy/extras/framebuffer_display.py @@ -111,7 +111,7 @@ class FramebufferDisplay: # Defines the current thread that hold the display session to the fb self._framebuffer_thread = None - self.is_busy = False + self._framebuffer_lock = threading.Lock() # Keeps device open to spare transfer times self._fb_device = open(self.get_framebuffer_path(), mode='r+b') @@ -120,7 +120,16 @@ class FramebufferDisplay: mmap.MAP_SHARED, mmap.PROT_WRITE | mmap.PROT_READ) - self.clear_framebuffer_threaded() + self.clear_buffer_threaded() + + def is_busy(self) -> bool: + """ + Checks if the device is busy. + @return: True if the device is busy, false otherwise. + """ + return (self._framebuffer_lock.locked() or + (self._framebuffer_thread is not None + and self._framebuffer_thread.is_alive())) def get_framebuffer_path(self) -> str: """ @@ -153,30 +162,57 @@ class FramebufferDisplay: with Image.open(path) as img: return img.tobytes() - def clear_framebuffer(self): + def clear_buffer(self): """ Clears the display with zeros (black background). """ - self.is_busy = True - color = 0 - self.fb_memory_map.write(color.to_bytes(1, byteorder='little') - * self.fb_maxsize) - self.fb_memory_map.seek(0) - self.is_busy = False + self.fill_buffer(0) - def clear_framebuffer_threaded(self, wait_thread=False): + def clear_buffer_threaded(self, wait_thread=False): """ - Wait for the framebuffer thread to terminate (Finish writes). + Clears the display with zeros (black background). @param wait_thread: Wait for the framebuffer thread to terminate. """ self.wait_framebuffer_thread() self._framebuffer_thread = threading.Thread( - target=self.clear_framebuffer, + target=self.clear_buffer, name=f"Clears the fb{self.framebuffer_index}") self._framebuffer_thread.start() if wait_thread: self._framebuffer_thread.join() + def fill_buffer(self, color): + """ + Fills the framebuffer with the given color. + @param color: From 0 to 255 + """ + if not isinstance(color, int): + msg = "The color must be an int number from 0 to 255." + logging.exception(msg) + raise Exception(msg) + + if color < 0 or color > 255: + msg = "The color must be an int number from 0 to 255." + logging.exception(msg) + raise Exception(msg) + + self.send_buffer(color.to_bytes(1, byteorder='little') + * self.fb_maxsize) + + def fill_buffer_threaded(self, color, wait_thread=False): + """ + Fills the framebuffer with the given color. + @param color: From 0 to 255 + @param wait_thread: Wait for the framebuffer thread to terminate. + """ + self.wait_framebuffer_thread() + self._framebuffer_thread = threading.Thread( + target=self.fill_buffer, args=(color,), + name=f"Fill the fb{self.framebuffer_index} with {color}") + self._framebuffer_thread.start() + if wait_thread: + self._framebuffer_thread.join() + def send_buffer(self, buffer, clear_first=False, offset=0): """ Send a byte array to the framebuffer device. @@ -199,27 +235,24 @@ class FramebufferDisplay: logging.exception(msg) raise Exception(msg) - # open framebuffer and map it onto a python bytearray - self.is_busy = True + with self._framebuffer_lock: + if clear_first: + color = 0 + self.fb_memory_map.seek(0) + self.fb_memory_map.write( + color.to_bytes(1, byteorder='little') + * self.fb_maxsize) - if clear_first: - color = 0 - self.fb_memory_map.write( - color.to_bytes(1, byteorder='little') * self.fb_maxsize) + buffer_size = len(buffer) + if buffer_size + offset > self.fb_maxsize: + msg = (f"The buffer size of {buffer_size} + {offset} is larger " + f"than actual framebuffer size of {self.fb_maxsize}.") + logging.exception(msg) + raise Exception(msg) - buffer_size = len(buffer) - if buffer_size + offset > self.fb_maxsize: - msg = (f"The buffer size of {buffer_size} + {offset} is larger " - f"than actual framebuffer size of {self.fb_maxsize}.") - logging.exception(msg) - raise Exception(msg) - - if offset > 0: self.fb_memory_map.seek(offset) - - self.fb_memory_map.write(buffer) - self.fb_memory_map.seek(0) - self.is_busy = False + self.fb_memory_map.write(buffer) + self.fb_memory_map.seek(0) def send_buffer_threaded(self, buffer, clear_first=False, offset=0, wait_thread=False): @@ -262,7 +295,6 @@ class FramebufferDisplay: logging.exception(msg) raise Exception(msg) - self.is_busy = True buffer = self.get_image_buffer(path) self.send_buffer(buffer, clear_first, offset) @@ -298,13 +330,16 @@ class FramebufferDisplayWrapper(FramebufferDisplay): device_name, self.cmd_FRAMEBUFFER_SEND_IMAGE, desc=self.cmd_FRAMEBUFFER_SEND_IMAGE_help) + def get_status(self, eventtime): + return {'is_busy': self.is_busy()} + cmd_FRAMEBUFFER_CLEAR_help = ("Clears the display with zeros " "(black background)") def cmd_FRAMEBUFFER_CLEAR(self, gcmd): wait_thread = gcmd.get_int('WAIT', 0, 0, 1) - self.clear_framebuffer_threaded(wait_thread) + self.clear_buffer_threaded(wait_thread) cmd_FRAMEBUFFER_SEND_IMAGE_help = ("Send a image from a path " "and render it on the framebuffer.") diff --git a/klippy/extras/msla_display.py b/klippy/extras/msla_display.py index 4fcc0e443..a5e6bf4ec 100644 --- a/klippy/extras/msla_display.py +++ b/klippy/extras/msla_display.py @@ -74,6 +74,7 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): # Variables self._sd = self.printer.lookup_object("virtual_sdcard") + self._m6054_file_regex = re.compile(r'F(.+[.](png|jpg|jpeg|bmp|gif))') # Events self.printer.register_event_handler("virtual_sdcard:reset_file", @@ -84,6 +85,9 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): gcode.register_command("MSLA_DISPLAY_VALIDATE", self.cmd_MSLA_DISPLAY_VALIDATE, desc=self.cmd_MSLA_DISPLAY_VALIDATE_help) + gcode.register_command("MSLA_DISPLAY_TEST", + self.cmd_MSLA_DISPLAY_TEST, + desc=self.cmd_MSLA_DISPLAY_TEST_help) gcode.register_command("MSLA_DISPLAY_RESPONSE_TIME", self.cmd_MSLA_DISPLAY_RESPONSE_TIME, desc=self.cmd_MSLA_DISPLAY_RESPONSE_TIME_help) @@ -97,7 +101,7 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): desc=self.cmd_M1400_help) def get_status(self, eventtime): - return {'display_busy': self.is_busy, + return {'display_busy': self.is_busy(), 'is_exposure': self.is_exposure(), 'uvled_value': self.uvled.last_value * 255.} @@ -270,6 +274,44 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): raise gcmd.error(f"The pixel height of {pixel_height} is invalid. " f"Should be {self.pixel_height}.") + cmd_MSLA_DISPLAY_TEST_help = ("Test the display by showing full white image" + " and grey shades. Use a white paper on top " + "of display and confirm if the pixels are " + "healthy.") + + def cmd_MSLA_DISPLAY_TEST(self, gcmd): + """ + Test the display by showing full white and grey shades. + Use a white paper on top of display and confirm if the pixels + are healthy. + + Syntax: MSLA_DISPLAY_TEST DELAY=[3000] + DELAY: Time in milliseconds between tests (int) + @param gcmd: + @return: + """ + if self._sd.current_file is not None: + gcmd.respond_raw("MSLA_DISPLAY_TEST: Can not run this command while" + " printing.") + return + + delay = gcmd.get_int('DELAY', 3000, 1000, 5000) / 1000. + + toolhead = self.printer.lookup_object('toolhead') + color = 255 + decrement = 45 + + self.set_uvled_on() + while color > 0: + gcmd.respond_raw(f"Fill color: {color}") + self.fill_buffer(color) + color -= decrement + toolhead.dwell(delay) + self.set_uvled_off() + self.clear_buffer() + gcmd.respond_raw(f"Test finished.") + + cmd_MSLA_DISPLAY_RESPONSE_TIME_help = ("Sends a buffer to display and test " "it response time to complete the " "render.") @@ -280,7 +322,6 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): to complete the render. Syntax: MSLA_DISPLAY_RESPONSE_TIME AVG=[x] - AVG: Number of samples to average (int) @param gcmd: @return: @@ -317,7 +358,7 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): time_sum = 0 for _ in range(avg): timems = time.time() - self.clear_framebuffer() + self.clear_buffer() time_sum += time.time() - timems gcmd.respond_raw('Clear time: %fms (%d samples)' % (round(time_sum * 1000 / avg, 6), avg)) @@ -343,7 +384,7 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): toolhead = self.printer.lookup_object('toolhead') params = gcmd.get_raw_command_parameters().strip() - match = re.search(r'F(.+[.](png|jpg|jpeg|bmp|gif))', params) + match = self._m6054_file_regex.search(params) if match: path = match.group(1).strip(' "\'') path = os.path.normpath(os.path.expanduser(path)) @@ -351,14 +392,14 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): if self.is_exposure(): # LED is ON, sync to prevent image change while exposure toolhead.wait_moves() - self.send_buffer(buffer, clear_first, offset) + self.send_buffer_threaded(buffer, clear_first, offset, wait_thread) else: if 'F' in params: raise gcmd.error('M6054: The F parameter is malformed.' 'Use a proper image file.') if self.is_exposure(): toolhead.wait_moves() - self.clear_framebuffer_threaded(wait_thread) + self.clear_buffer_threaded(wait_thread) def is_exposure(self) -> bool: """ @@ -403,6 +444,23 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): lambda print_time: self.uvled._set_pin(print_time, 0)) toolhead.wait_moves() + def set_uvled_on(self, delay=0): + """ + Turns the UV LED on with max power + @param delay: If set, it will switch off from on state after specified + delay. This also ensures that the UV LED is in sync with + the display. + @return: + """ + self.set_uvled(255, delay) + + def set_uvled_off(self): + """ + Turns the UV LED off + @return: + """ + self.set_uvled(0) + cmd_MSLA_UVLED_RESPONSE_TIME_help = ("Tests the response time for the " "UV LED shutter.") @@ -419,7 +477,7 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): @return: """ self.set_uvled(0) - self.clear_framebuffer() + self.clear_buffer() delay = gcmd.get_int('TIME', 3000, minval=500, maxval=5000) / 1000. offset = gcmd.get_int('OFFSET', 0, minval=-1000, maxval=0) / 1000. From 4f138be2dfdf5f9e5c9dacbd381ccc6861a1c736 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Concei=C3=A7=C3=A3o?= Date: Sat, 27 Apr 2024 22:39:46 +0100 Subject: [PATCH 16/32] Prevent some commands to run while printing --- docs/Config_Reference.md | 2 +- klippy/extras/framebuffer_display.py | 4 ---- klippy/extras/msla_display.py | 19 ++++++++++++++++--- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/docs/Config_Reference.md b/docs/Config_Reference.md index f0db7769f..dac2d49b9 100644 --- a/docs/Config_Reference.md +++ b/docs/Config_Reference.md @@ -4391,7 +4391,7 @@ model: LS055R1SX04 # The display model, informative only. pixel_format: RGB # The display pixel format, possible values: -# Mono, RGB, BGR, RGBA, BGRA +# Mono, RGB, BGR # Default: RGB framebuffer_index: 0 # The framebuffer index, this maps to /dev/fb diff --git a/klippy/extras/framebuffer_display.py b/klippy/extras/framebuffer_display.py index 7f1080d6a..e943b4b3a 100644 --- a/klippy/extras/framebuffer_display.py +++ b/klippy/extras/framebuffer_display.py @@ -21,9 +21,7 @@ class FramebufferDisplay: self.model = config.get('model', None) atypes = {'Mono': 'Mono', 'RGB': 'RGB', - 'RGBA': 'RGBA', 'BGR': 'BGR', - 'BGRA': 'BGRA' } self.pixel_format = config.getchoice('pixel_format', atypes, default='RGB') @@ -48,8 +46,6 @@ class FramebufferDisplay: self.bit_depth = 8 if self.pixel_format == 'RGB' or self.pixel_format == 'BGR': self.bit_depth = 24 - if self.pixel_format == 'RGBA' or self.pixel_format == 'BGRA': - self.bit_depth = 32 else: self.bit_depth = len(self.pixel_format) * 8 diff --git a/klippy/extras/msla_display.py b/klippy/extras/msla_display.py index a5e6bf4ec..03fc12460 100644 --- a/klippy/extras/msla_display.py +++ b/klippy/extras/msla_display.py @@ -315,7 +315,6 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): cmd_MSLA_DISPLAY_RESPONSE_TIME_help = ("Sends a buffer to display and test " "it response time to complete the " "render.") - def cmd_MSLA_DISPLAY_RESPONSE_TIME(self, gcmd): """ Sends a buffer to display and test it response time @@ -326,6 +325,10 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): @param gcmd: @return: """ + if self._sd.current_file is not None: + gcmd.respond_raw("MSLA_DISPLAY_RESPONSE_TIME: Can not run this " + "command while printing.") + avg = gcmd.get_int('AVG', 1, 1, 20) fb_maxsize_mb = round(self.fb_maxsize / 1024.0 / 1024.0, 2) @@ -392,14 +395,20 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): if self.is_exposure(): # LED is ON, sync to prevent image change while exposure toolhead.wait_moves() - self.send_buffer_threaded(buffer, clear_first, offset, wait_thread) + self.send_buffer_threaded(buffer, clear_first, + offset, True) + else: + self.send_buffer_threaded(buffer, clear_first, + offset, wait_thread) else: if 'F' in params: raise gcmd.error('M6054: The F parameter is malformed.' 'Use a proper image file.') if self.is_exposure(): toolhead.wait_moves() - self.clear_buffer_threaded(wait_thread) + self.clear_buffer_threaded(True) + else: + self.clear_buffer_threaded(wait_thread) def is_exposure(self) -> bool: """ @@ -476,6 +485,10 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): @param gcmd: @return: """ + if self._sd.current_file is not None: + gcmd.respond_raw("MSLA_UVLED_RESPONSE_TIME: Can not run this " + "command while printing.") + self.set_uvled(0) self.clear_buffer() From 649dee66b95225f4b6ee9c0915d0b4274071cf26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Concei=C3=A7=C3=A3o?= Date: Mon, 29 Apr 2024 23:22:10 +0100 Subject: [PATCH 17/32] improve framebuffer system - Add channels information - Add fill_buffer allow a rgb tuple - Add M1401 command to turn off the uvled - Improve image send to allow images smaller than buffer - Improve get_image_buffer to return more data and allow strip alpha - Improve raise to gcode.error - Add utility functions - Rename some commands --- docs/G-Codes.md | 8 +- klippy/extras/framebuffer_display.py | 360 +++++++++++++++++++-------- klippy/extras/msla_display.py | 59 +++-- 3 files changed, 297 insertions(+), 130 deletions(-) diff --git a/docs/G-Codes.md b/docs/G-Codes.md index 638c7e72d..ffff52e52 100644 --- a/docs/G-Codes.md +++ b/docs/G-Codes.md @@ -330,12 +330,12 @@ is enabled. - Clear the framebuffer (fill with zeros): `FRAMEBUFFER_CLEAR DEVICE= WAIT=[0/1]` -- Send image: `FRAMEBUFFER_SEND_IMAGE DEVICE= PATH= CLEAR=[0/1] +- Send image: `FRAMEBUFFER_DRAW_IMAGE DEVICE= PATH= CLEAR=[0/1/2] OFFSET=[n] WAIT=[0/1]` - `DEVICE`: Configured device name in [framebuffer_display name] - `PATH`: Absolute image path - - `CLEAR`: Clear the buffer before send the image - `OFFSET`: Offset from buffer start to write the image + - `CLEAR`: Clear the remaining buffer, 0=No, 1=Yes, 2=Auto. (int) Default: 2 - `WAIT`: Wait for the render to complete ### [msla_display] @@ -356,7 +356,8 @@ is enabled. - Display image: `M6054 F<"image.png"> C[0/1] W[0/1]` - `F`: Image file to display, when printing this file is relative to the base path of the print gcode - - `C`: Clear the buffer before send the image + - `O`: Positive offset from start position of the buffer + - `C`: Clear the remaining buffer, 0=No, 1=Yes, 2=Auto. (int) Default: 2 - `W`: Wait for the render to complete - Display clear: `M6054` - Tests the UV LED response time: @@ -368,6 +369,7 @@ is enabled. - Set the UV LED power: `M1400 S<0-255> P[ms]` - `S`: The LED Power (Non PWM LEDs will turn on from 1 to 255). - `P`: Time to wait in milliseconds when (S>0) before turn off. +- Turn off the UV LED: `M1401` ### [dual_carriage] diff --git a/klippy/extras/framebuffer_display.py b/klippy/extras/framebuffer_display.py index e943b4b3a..7dcab5517 100644 --- a/klippy/extras/framebuffer_display.py +++ b/klippy/extras/framebuffer_display.py @@ -1,4 +1,4 @@ -# mSLA and DLP display properties +# Write to framebuffer devices (HDMI and DSI) display # # Copyright (C) 2024 Tiago Conceicao # @@ -13,10 +13,14 @@ import mmap from PIL import Image import threading +CLEAR_NO_FLAG = 0 +CLEAR_YES_FLAG = 1 +CLEAR_AUTO_FLAG = 2 class FramebufferDisplay: def __init__(self, config): self.printer = config.get_printer() + self.gcode = self.printer.lookup_object('gcode') self.model = config.get('model', None) atypes = {'Mono': 'Mono', @@ -43,11 +47,14 @@ class FramebufferDisplay: self.display_height = round(self.resolution_y * self.pixel_height, 4) if self.pixel_format == 'Mono': + self.channels = 1 self.bit_depth = 8 if self.pixel_format == 'RGB' or self.pixel_format == 'BGR': + self.channels = 3 self.bit_depth = 24 else: - self.bit_depth = len(self.pixel_format) * 8 + self.channels = len(self.pixel_format) + self.bit_depth = self.channels * 8 self.is_landscape = self.resolution_x >= self.resolution_y self.is_portrait = not self.is_landscape @@ -127,13 +134,6 @@ class FramebufferDisplay: (self._framebuffer_thread is not None and self._framebuffer_thread.is_alive())) - def get_framebuffer_path(self) -> str: - """ - Gets the framebuffer device path associated with this object. - @return: /dev/fb[i] - """ - return f"/dev/fb{self.framebuffer_index}" - def wait_framebuffer_thread(self, timeout=None) -> bool: """ Wait for the framebuffer thread to terminate (Finish writes). @@ -147,16 +147,98 @@ class FramebufferDisplay: return True return False - def get_image_buffer(self, path) -> bytes: + def get_framebuffer_path(self) -> str: """ - Reads an image from disk and return it buffer, ready to send. + Gets the framebuffer device path associated with this object. + @return: /dev/fb[i] + """ + return f"/dev/fb{self.framebuffer_index}" + + def get_max_offset(self, buffer_size, stride, height=-1): + """ + Gets the maximum possible offset of the framebuffer device given a + buffer and it stride. + @param buffer_size: The total buffer size + @param stride: The buffer stride, how many bytes per row + @param height: The buffer height + @return: + """ + if height < 0: + height = buffer_size // stride + return max(self.fb_maxsize - self.fb_stride * (height - 1) - stride, 0) + + def get_position_from_xy(self, x, y) -> int: + """ + Gets the starting point in the buffer given an x and y coordinate + @param x: X coordinate + @param y: Y coordinate + @return: Buffer position + """ + if x < 0: + msg = "The x value can not be negative." + logging.exception(msg) + raise self.gcode.error(msg) + + if x >= self.resolution_x: + msg = f"The x value must be less than {self.resolution_x}" + logging.exception(msg) + raise self.gcode.error(msg) + + if y < 0: + msg = "The y value can not be negative." + logging.exception(msg) + raise self.gcode.error(msg) + + if y >= self.resolution_y: + msg = f"The y value must be less than {self.resolution_y}" + logging.exception(msg) + raise self.gcode.error(msg) + + return y * self.fb_stride + x * self.channels + + def get_image_buffer(self, path, strip_alpha=True) -> dict: + """ + Reads an image from disk and return it buffer, ready to send to fb. Note that it does not do any file check, it will always try to open an image file. @param path: Image path - @return: bytes buffer + @param strip_alpha: Strip the alpha channel of the image if present + @return: dict with image data """ with Image.open(path) as img: - return img.tobytes() + depth = 3 + if img.mode in ("L", "P"): + depth = 1 + elif img.mode in ("RGBA", "CMYK"): + depth = 4 + + if img.mode == "RGBA" and strip_alpha: + depth = 3 + # Strip alpha + with Image.new('RGB', img.size) as img2: + img2.paste(img, (0, 0), img) + buffer = img2.tobytes() + width = img.width + height = img.height + else: + buffer = img.tobytes() + width = img.width + height = img.height + + buffer_size = len(buffer) + #logging.exception(f"buffer: {buffer_size}, " + # f"stride: {width * depth}, " + # f"width: {width}, " + # f"height: {height}, " + # f"depth: {depth}, " + # f"bpp: {depth * 8}") + return {'buffer': buffer, + 'stride': img.width * depth, + 'width': width, + 'height': height, + 'depth': depth, + 'bpp': depth * 8, + 'size': buffer_size} def clear_buffer(self): """ @@ -177,28 +259,155 @@ class FramebufferDisplay: if wait_thread: self._framebuffer_thread.join() + def write_buffer(self, buffer, buffer_stride=0, offset=0, + clear_flag=CLEAR_NO_FLAG): + """ + Write a byte array into the framebuffer device. + @param buffer: A 1D byte array with data to send + @param offset: Sets a positive offset from start position of the buffer. + @param buffer_stride: The stride/row of the buffer, set if buffer is a + cropped region. + @param clear_flag: Clear the remaining buffer, 0=No, 1=Yes, 2=Auto + """ + if buffer is None or len(buffer) == 0: + return + + if not isinstance(buffer, (list, bytes)): + msg = (f"The buffer must be a 1D byte array. " + f"{type(buffer)} was passed") + logging.exception(msg) + raise self.gcode.error(msg) + + if offset < 0: + msg = f"The offset {offset} can not be negative value." + logging.exception(msg) + raise self.gcode.error(msg) + + if clear_flag < 0 or clear_flag > CLEAR_AUTO_FLAG: + msg = f"The clear flag must be between 0 and {CLEAR_AUTO_FLAG}." + raise self.gcode.error(msg) + + clear = clear_flag + + buffer_size = len(buffer) + if buffer_size + offset > self.fb_maxsize: + msg = (f"The buffer size of {buffer_size} + {offset} is greater " + f"than actual framebuffer size of {self.fb_maxsize}.") + logging.exception(msg) + raise self.gcode.error(msg) + + # Auto clear, clears if buffer is smaller than framebuffer device + if clear_flag == CLEAR_AUTO_FLAG and buffer_size != self.fb_maxsize: + clear = CLEAR_YES_FLAG + + is_region = False + if buffer_stride > 0 and buffer_stride < self.fb_stride: + is_region = True + if buffer_size % buffer_stride != 0: + msg = (f"The buffer stride of {buffer_stride} must be an exact" + f" multiple of the buffer size {buffer_size}.") + logging.exception(msg) + raise self.gcode.error(msg) + + max_offset = self.get_max_offset(buffer_size, buffer_stride) + if offset > max_offset: + msg = ("The offset of %d can not be greater than %d in order " + "to fit the a buffer of %d." % + (offset, max_offset, buffer_size)) + logging.exception(msg) + raise self.gcode.error(msg) + + with self._framebuffer_lock: + if offset > 0: + if clear: + self.fb_memory_map.write( + (0).to_bytes(1, byteorder='little') * offset) + else: + self.fb_memory_map.seek(offset) + + if is_region: + stride_offset = self.fb_stride - buffer_stride + if clear: + stride_cleaner = (0).to_bytes(1, byteorder='little' + ) * stride_offset + for i in range(0, buffer_size, buffer_stride): + if i > 0: + if clear: + self.fb_memory_map.write(stride_cleaner) + else: + self.fb_memory_map.seek(stride_offset, os.SEEK_CUR) + self.fb_memory_map.write(buffer[i:i + buffer_stride]) + else: + self.fb_memory_map.write(buffer) + + pos = self.fb_memory_map.tell() + self.gcode.error(f"pos: {pos}") + if clear and pos < self.fb_maxsize: + self.fb_memory_map.write( + (0).to_bytes(1, byteorder='little') + * (self.fb_maxsize - pos)) + + self.fb_memory_map.seek(0) + + def write_buffer_threaded(self, buffer, buffer_stride=0, offset=0, + clear_flag=CLEAR_NO_FLAG, wait_thread=False): + """ + Write a byte array into the framebuffer device in a separate thread. + @param buffer: A 1D byte array with data to send + @param buffer_stride: The stride/row of the buffer, set if buffer is a + cropped region. + @param offset: Sets a positive offset from start position of the buffer. + @param clear_flag: Clear the remaining buffer, 0=No, 1=Yes, 2=Auto + @param wait_thread: If true wait for the framebuffer thread to terminate + + """ + self.wait_framebuffer_thread() + self._framebuffer_thread = threading.Thread( + target=lambda: self.write_buffer(buffer, buffer_stride, offset, + clear_flag), + name=f"Sends an buffer to fb{self.framebuffer_index}") + self._framebuffer_thread.start() + if wait_thread: + self._framebuffer_thread.join() + def fill_buffer(self, color): """ Fills the framebuffer with the given color. - @param color: From 0 to 255 + @param color: From 0 to 255 or a tuple/list of RGB """ - if not isinstance(color, int): - msg = "The color must be an int number from 0 to 255." - logging.exception(msg) - raise Exception(msg) + if isinstance(color, int): + if color < 0 or color > 255: + msg = "The color must be an int number from 0 to 255." + logging.exception(msg) + raise self.gcode.error(msg) - if color < 0 or color > 255: - msg = "The color must be an int number from 0 to 255." + buffer = color.to_bytes(1, byteorder='little') + elif isinstance(color, (tuple, list)): + for x in color: + if x < 0 or x > 255: + msg = "The color must be an int number from 0 to 255." + logging.exception(msg) + raise self.gcode.error(msg) + buffer = bytes(color) + else: + msg = ("The color must be an int number from 0 to 255 " + "or a tuple/list of RGB.") logging.exception(msg) - raise Exception(msg) + raise self.gcode.error(msg) - self.send_buffer(color.to_bytes(1, byteorder='little') - * self.fb_maxsize) + buffer_size = len(buffer) + if self.fb_maxsize % buffer_size != 0: + msg = ( + f"The buffer size of {buffer_size} must be an exact" + f" multiple of the framebuffer size {self.fb_maxsize}.") + logging.exception(msg) + raise self.gcode.error(msg) + self.write_buffer(buffer * (self.fb_maxsize // buffer_size)) def fill_buffer_threaded(self, color, wait_thread=False): """ Fills the framebuffer with the given color. - @param color: From 0 to 255 + @param color: From 0 to 255 or a tuple/list of RGB @param wait_thread: Wait for the framebuffer thread to terminate. """ self.wait_framebuffer_thread() @@ -209,104 +418,44 @@ class FramebufferDisplay: if wait_thread: self._framebuffer_thread.join() - def send_buffer(self, buffer, clear_first=False, offset=0): + def draw_image(self, path, offset=0, clear_flag=CLEAR_NO_FLAG): """ - Send a byte array to the framebuffer device. - @param buffer: A 1D byte array with data to send - @param clear_first: If true first clears the display and then, - renders the image. - @param offset: Sets a positive offset from start position of the buffer. - """ - if buffer is None or len(buffer) == 0: - return - - if not isinstance(buffer, (list, bytes)): - msg = (f"The buffer must be a 1D byte array. " - f"{type(buffer)} was passed") - logging.exception(msg) - raise Exception(msg) - - if offset < 0: - msg = f"The offset {offset} can not be negative value." - logging.exception(msg) - raise Exception(msg) - - with self._framebuffer_lock: - if clear_first: - color = 0 - self.fb_memory_map.seek(0) - self.fb_memory_map.write( - color.to_bytes(1, byteorder='little') - * self.fb_maxsize) - - buffer_size = len(buffer) - if buffer_size + offset > self.fb_maxsize: - msg = (f"The buffer size of {buffer_size} + {offset} is larger " - f"than actual framebuffer size of {self.fb_maxsize}.") - logging.exception(msg) - raise Exception(msg) - - self.fb_memory_map.seek(offset) - self.fb_memory_map.write(buffer) - self.fb_memory_map.seek(0) - - def send_buffer_threaded(self, buffer, clear_first=False, offset=0, - wait_thread=False): - """ - Send a byte array to the framebuffer device in a separate thread. - @param buffer: A 1D byte array with data to send - @param clear_first: If true first clears the display - and then renders the image. - @param offset: Sets a positive offset from start position of the buffer. - @param wait_thread: If true wait for the framebuffer thread to terminate - """ - self.wait_framebuffer_thread() - self._framebuffer_thread = threading.Thread( - target=lambda: self.send_buffer(buffer, clear_first, offset), - name=f"Sends an buffer to fb{self.framebuffer_index}") - self._framebuffer_thread.start() - if wait_thread: - self._framebuffer_thread.join() - - def send_image(self, path, clear_first=False, offset=0): - """ - Reads an image from a path and sends the bitmap to the fb device. + Reads an image from a path and draw the bitmap to the fb device. @param path: Image file path to be read. - @param clear_first: If true first clears the display and - then renders the image. @param offset: Sets a positive offset from start position of the buffer. + @param clear_flag: Clear the remaining buffer, 0=No, 1=Yes, 2=Auto """ + gcode = self.printer.lookup_object('gcode') if offset < 0: msg = f"The offset {offset} can not be negative value." logging.exception(msg) - raise Exception(msg) + raise gcode.error(msg) if not isinstance(path, str): msg = f"Path must be a string." logging.exception(msg) - raise Exception(msg) + raise gcode.error(msg) if not os.path.isfile(path): msg = f"The file '{path}' does not exists." logging.exception(msg) - raise Exception(msg) + raise gcode.error(msg) - buffer = self.get_image_buffer(path) - self.send_buffer(buffer, clear_first, offset) + di = self.get_image_buffer(path) + self.write_buffer(di['buffer'], di['stride'], offset, clear_flag) - def send_image_threaded(self, path, clear_first=False, offset=0, + def draw_image_threaded(self, path, offset=0, clear_flag=CLEAR_NO_FLAG, wait_thread=False): """ - Reads an image from a path and sends the bitmap to the fb device. + Reads an image from a path and draw the bitmap to the fb device. @param path: Image file path to be read. - @param clear_first: If true first clears the display - and then renders the image. @param offset: Sets a positive offset from start position of the buffer. + @param clear_flag: Clear the remaining buffer, 0=No, 1=Yes, 2=Auto @param wait_thread: If true wait for the framebuffer thread to terminate """ self.wait_framebuffer_thread() self._framebuffer_thread = threading.Thread( - target=lambda: self.send_image(path, clear_first, offset), + target=lambda: self.draw_image(path, offset, clear_flag), name=f"Render an image to fb{self.framebuffer_index}") self._framebuffer_thread.start() if wait_thread: @@ -318,13 +467,12 @@ class FramebufferDisplayWrapper(FramebufferDisplay): super().__init__(config) device_name = config.get_name().split()[1] - gcode = self.printer.lookup_object('gcode') - gcode.register_mux_command("FRAMEBUFFER_CLEAR", "DEVICE", device_name, + self.gcode.register_mux_command("FRAMEBUFFER_CLEAR", "DEVICE", device_name, self.cmd_FRAMEBUFFER_CLEAR, desc=self.cmd_FRAMEBUFFER_CLEAR_help) - gcode.register_mux_command("FRAMEBUFFER_SEND_IMAGE", "DEVICE", - device_name, self.cmd_FRAMEBUFFER_SEND_IMAGE, - desc=self.cmd_FRAMEBUFFER_SEND_IMAGE_help) + self.gcode.register_mux_command("FRAMEBUFFER_SEND_IMAGE", "DEVICE", + device_name, self.cmd_FRAMEBUFFER_DRAW_IMAGE, + desc=self.cmd_FRAMEBUFFER_DRAW_IMAGE_help) def get_status(self, eventtime): return {'is_busy': self.is_busy()} @@ -337,19 +485,19 @@ class FramebufferDisplayWrapper(FramebufferDisplay): self.clear_buffer_threaded(wait_thread) - cmd_FRAMEBUFFER_SEND_IMAGE_help = ("Send a image from a path " + cmd_FRAMEBUFFER_DRAW_IMAGE_help = ("Reads a image from a path " "and render it on the framebuffer.") - def cmd_FRAMEBUFFER_SEND_IMAGE(self, gcmd): + def cmd_FRAMEBUFFER_DRAW_IMAGE(self, gcmd): path = gcmd.get('PATH') - clear_first = gcmd.get_int('CLEAR', 0, 0, 1) offset = gcmd.get_int('OFFSET', 0, 0) + clear_flag = gcmd.get_int('CLEAR', CLEAR_AUTO_FLAG, 0, CLEAR_AUTO_FLAG) wait_thread = gcmd.get_int('WAIT', 0, 0, 1) if not os.path.isfile(path): raise gcmd.error(f"The file '{path}' does not exists.") - self.send_image_threaded(path, clear_first, offset, wait_thread) + self.draw_image_threaded(path, offset, clear_flag, wait_thread) def load_config_prefix(config): diff --git a/klippy/extras/msla_display.py b/klippy/extras/msla_display.py index 03fc12460..41e7060e5 100644 --- a/klippy/extras/msla_display.py +++ b/klippy/extras/msla_display.py @@ -99,6 +99,8 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): desc=self.cmd_MSLA_UVLED_RESPONSE_TIME_help) gcode.register_command('M1400', self.cmd_M1400, desc=self.cmd_M1400_help) + gcode.register_command('M1401', self.cmd_M1401, + desc=self.cmd_M1401_help) def get_status(self, eventtime): return {'display_busy': self.is_busy(), @@ -151,8 +153,8 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): layer_index += 1 new_path = last_cache.new_path_layerindex(layer_index) if os.path.isfile(new_path): - buffer = self.get_image_buffer(new_path) - self._cache.append(BufferCache(new_path, buffer)) + di = self.get_image_buffer(new_path) + self._cache.append(BufferCache(new_path, di)) def _get_cache_index(self, path) -> int: """ @@ -165,7 +167,7 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): return i return -1 - def _get_image_buffercache(self, gcmd, path) -> bytes: + def _get_image_buffercache(self, gcmd, path): """ Gets a image buffer from cache if available, otherwise it creates the buffer from the path image @@ -193,19 +195,19 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): target=self._process_cache, args=(cache,)) self._cache_thread.start() - return cache.buffer + return cache.data if not os.path.isfile(path): raise gcmd.error(f"M6054: The file '{path}' does not exists.") - buffer = self.get_image_buffer(path) + di = self.get_image_buffer(path) if current_filepath and self._can_cache(): - cache = BufferCache(path, buffer) + cache = BufferCache(path, di) self._cache_thread = threading.Thread(target=self._process_cache, args=(cache,)) self._cache_thread.start() - return buffer + return di cmd_MSLA_DISPLAY_VALIDATE_help = ("Validate the display against resolution " "and pixel parameters. Throw error if out" @@ -344,7 +346,7 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): image.save(path, "PNG") for _ in range(avg): timems = time.time() - self.send_image(path) + self.draw_image(path) time_sum += time.time() - timems gcmd.respond_raw('Read image from disk + send time: %fms (%d samples)' % (round(time_sum * 1000 / avg, 6), avg)) @@ -353,7 +355,7 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): buffer = bytes(os.urandom(self.fb_maxsize)) for _ in range(avg): timems = time.time() - self.send_buffer(buffer) + self.write_buffer(buffer) time_sum += time.time() - timems gcmd.respond_raw('Send cached buffer: %fms (%d samples)' % (round(time_sum * 1000 / avg, 6), avg)) @@ -374,15 +376,16 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): Display an image from a path and display it on the main display. No parameter clears the display. - Syntax: M6054 F<"file.png"> C[0/1] O[x] W[0/1] + Syntax: M6054 F<"file.png"> O[x] C[0/1/3] W[0/1] F: File path eg: "1.png". quotes are optional. (str) - C: Clear the image before display the new image. (int) + O: Sets a positive offset from start position of the buffer. (int) + C: Clear the remaining buffer, 0=No, 1=Yes, 2=Auto. (int) Default: 2 W: Wait for buffer to complete the transfer. (int) @param gcmd: @return: """ - clear_first = gcmd.get_int('C', 0, 0, 1) - offset = gcmd.get_int('O', 0, 0) + clear_flag = gcmd.get_int('C', framebuffer_display.CLEAR_AUTO_FLAG, 0, + framebuffer_display.CLEAR_AUTO_FLAG) wait_thread = gcmd.get_int('W', 0, 0, 1) toolhead = self.printer.lookup_object('toolhead') @@ -391,15 +394,19 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): if match: path = match.group(1).strip(' "\'') path = os.path.normpath(os.path.expanduser(path)) - buffer = self._get_image_buffercache(gcmd, path) + di = self._get_image_buffercache(gcmd, path) + + max_offset = self.get_max_offset( + di['size'], di['stride'], di['height']) + offset = gcmd.get_int('O', 0, 0, max_offset) if self.is_exposure(): # LED is ON, sync to prevent image change while exposure toolhead.wait_moves() - self.send_buffer_threaded(buffer, clear_first, - offset, True) + self.write_buffer_threaded(di['buffer'], di['stride'], offset, + clear_flag) else: - self.send_buffer_threaded(buffer, clear_first, - offset, wait_thread) + self.write_buffer_threaded(di['buffer'], di['stride'], offset, + clear_flag) else: if 'F' in params: raise gcmd.error('M6054: The F parameter is malformed.' @@ -553,11 +560,21 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): delay = gcmd.get_float('P', 0, above=0.) / 1000. self.set_uvled(value, delay) + cmd_M1401_help = "Turn the main UV LED off." -class BufferCache(): - def __init__(self, path, buffer): + def cmd_M1401(self, gcmd): + """ + Turn the main UV LED off + + Syntax: M1401 + @param gcmd: + """ + self.set_uvled(0) + +class BufferCache: + def __init__(self, path, data): self.path = path - self.buffer = buffer + self.data = data self.pathinfo = Path(path) def new_path_layerindex(self, n) -> str: From 1eaa257e957f5fec54462d54bcbea75190cb21ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Concei=C3=A7=C3=A3o?= Date: Mon, 29 Apr 2024 23:23:11 +0100 Subject: [PATCH 18/32] Create zaxis.py Z axis with self homed xy and allow only to move Z --- klippy/kinematics/zaxis.py | 102 +++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 klippy/kinematics/zaxis.py diff --git a/klippy/kinematics/zaxis.py b/klippy/kinematics/zaxis.py new file mode 100644 index 000000000..53b1beb48 --- /dev/null +++ b/klippy/kinematics/zaxis.py @@ -0,0 +1,102 @@ +# Code for handling the kinematics of a single Z Axis +# This kinematic still need to fake XY presence +# +# Copyright (C) 2017-2020 Kevin O'Connor +# +# This file may be distributed under the terms of the GNU GPLv3 license. +import stepper + +class ZAxisKinematics: + def __init__(self, toolhead, config): + self.printer = config.get_printer() + # Setup axis rails + self.rails = [stepper.LookupMultiRail(config.getsection('stepper_' + n)) + for n in 'z'] + for rail, axis in zip(self.rails, 'z'): + rail.setup_itersolve('cartesian_stepper_alloc', axis.encode()) + ranges = [r.get_range() for r in self.rails] + self.axes_min = toolhead.Coord(0, 0, ranges[0][0], e=0.) + self.axes_max = toolhead.Coord(0, 0, ranges[0][1], e=0.) + + for s in self.get_steppers(): + s.set_trapq(toolhead.get_trapq()) + toolhead.register_step_generator(s.generate_steps) + self.printer.register_event_handler("stepper_enable:motor_off", + self._motor_off) + # Setup boundary checks + max_velocity, max_accel = toolhead.get_max_velocity() + self.max_z_velocity = config.getfloat('max_z_velocity', max_velocity, + above=0., maxval=max_velocity) + self.max_z_accel = config.getfloat('max_z_accel', max_accel, + above=0., maxval=max_accel) + self.limits = [(1.0, 1.0),(1.0, 1.0),(1.0, -1.0)] + def get_steppers(self): + return [s for rail in self.rails for s in rail.get_steppers()] + def calc_position(self, stepper_positions): + pos = [stepper_positions[rail.get_name()] for rail in self.rails] + return [0, 0, pos[0]] + def update_limits(self, i, range): + l, h = self.limits[i] + # Only update limits if this axis was already homed, + # otherwise leave in un-homed state. + if l <= h: + self.limits[i] = range + def override_rail(self, i, rail): + self.rails[i] = rail + def set_position(self, newpos, homing_axes): + for i, rail in enumerate(self.rails): + rail.set_position(newpos) + if 2 in homing_axes: + self.limits[2] = rail.get_range() + def note_z_not_homed(self): + # Helper for Safe Z Home + self.limits[2] = (1.0, -1.0) + def home_axis(self, homing_state, axis, rail): + # Determine movement + position_min, position_max = rail.get_range() + hi = rail.get_homing_info() + homepos = [None, None, None, None] + homepos[axis] = hi.position_endstop + forcepos = list(homepos) + if hi.positive_dir: + forcepos[axis] -= 1.5 * (hi.position_endstop - position_min) + else: + forcepos[axis] += 1.5 * (position_max - hi.position_endstop) + # Perform homing + homing_state.home_rails([rail], forcepos, homepos) + def home(self, homing_state): + # Each axis is homed independently and in order + for axis in homing_state.get_axes(): + if axis == 2: + self.home_axis(homing_state, axis, self.rails[0]) + #self.home_axis(homing_state, axis, self.rails[axis]) + def _motor_off(self, print_time): + self.limits = [(1.0, 1.0),(1.0, 1.0),(1.0, -1.0)] + def _check_endstops(self, move): + end_pos = move.end_pos + for i in (0, 1, 2,): + if (move.axes_d[i] + and (end_pos[i] < self.limits[i][0] + or end_pos[i] > self.limits[i][1])): + if self.limits[i][0] > self.limits[i][1]: + raise move.move_error("Must home axis first") + raise move.move_error() + def check_move(self, move): + if not move.axes_d[2]: + # Normal XY move - use defaults + return + # Move with Z - update velocity and accel for slower Z axis + self._check_endstops(move) + z_ratio = move.move_d / abs(move.axes_d[2]) + move.limit_speed( + self.max_z_velocity * z_ratio, self.max_z_accel * z_ratio) + def get_status(self, eventtime): + axes = [a for a, (l, h) in zip("z", self.limits) if l <= h] + return { + 'homed_axes': "".join(axes), + 'axis_minimum': self.axes_min, + 'axis_maximum': self.axes_max, + } + +def load_kinematics(toolhead, config): + return ZAxisKinematics(toolhead, config) From 44ba3d8b14a5089f6a66464b79c5013a0568a1f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Concei=C3=A7=C3=A3o?= Date: Mon, 29 Apr 2024 23:25:33 +0100 Subject: [PATCH 19/32] Introduce better and universal stats Instead of using filament schemantic, it now use universal material which can be set by user. It is to provide better and more accurate stats, while allowing material types other than filament. --- klippy/extras/print_stats.py | 75 +++++++++++++++++++++++++++++------- 1 file changed, 61 insertions(+), 14 deletions(-) diff --git a/klippy/extras/print_stats.py b/klippy/extras/print_stats.py index 668cd7d0c..f67803843 100644 --- a/klippy/extras/print_stats.py +++ b/klippy/extras/print_stats.py @@ -6,21 +6,38 @@ class PrintStats: def __init__(self, config): - printer = config.get_printer() - self.gcode_move = printer.load_object(config, 'gcode_move') - self.reactor = printer.get_reactor() + self.printer = config.get_printer() + self.gcode_move = self.printer.load_object(config, 'gcode_move') + self.reactor = self.printer.get_reactor() + self.material_type = '' + self.material_name = '' + self.material_unit = '' self.reset() + + # Register events + self.printer.register_event_handler("klippy:mcu_identify", + self._init_stats) # Register commands - self.gcode = printer.lookup_object('gcode') + self.gcode = self.printer.lookup_object('gcode') self.gcode.register_command( "SET_PRINT_STATS_INFO", self.cmd_SET_PRINT_STATS_INFO, desc=self.cmd_SET_PRINT_STATS_INFO_help) + def _init_stats(self): + self.toolhead = self.printer.lookup_object('toolhead') + if self.toolhead: + if self.toolhead.manufacturing_process == 'FDM': + self.material_type = 'filament' + self.material_unit = 'mm' + elif self.toolhead.manufacturing_process in ('SLA', 'mSLA', 'DLP'): + self.material_type = 'resin' + self.material_unit = 'ml' def _update_filament_usage(self, eventtime): - gc_status = self.gcode_move.get_status(eventtime) - cur_epos = gc_status['position'].e - self.filament_used += (cur_epos - self.last_epos) \ - / gc_status['extrude_factor'] - self.last_epos = cur_epos + if self.toolhead.manufacturing_process == 'FDM': + gc_status = self.gcode_move.get_status(eventtime) + cur_epos = gc_status['position'].e + self.material_used += (cur_epos - self.last_epos) \ + / gc_status['extrude_factor'] + self.last_epos = cur_epos def set_current_file(self, filename): self.reset() self.filename = filename @@ -59,7 +76,7 @@ class PrintStats: self.error_message = error_message eventtime = self.reactor.monotonic() self.total_duration = eventtime - self.print_start_time - if self.filament_used < 0.0000001: + if self.material_used < 0.0000001: # No positive extusion detected during print self.init_duration = self.total_duration - \ self.prev_pause_duration @@ -71,6 +88,12 @@ class PrintStats: minval=0) current_layer = gcmd.get_int("CURRENT_LAYER", self.info_current_layer, \ minval=0) + material_name = gcmd.get("MATERIAL_NAME", None) + material_unit = gcmd.get("MATERIAL_UNIT", None) + material_total = gcmd.get_float("MATERIAL_TOTAL", -1., 0.) + consume_material = gcmd.get_float("CONSUME_MATERIAL", 0., 0.) + + if total_layer == 0: self.info_total_layer = None self.info_current_layer = None @@ -82,15 +105,33 @@ class PrintStats: current_layer is not None and \ current_layer != self.info_current_layer: self.info_current_layer = min(current_layer, self.info_total_layer) + + if material_name: + self.material_name = material_name + + if material_unit: + self.material_unit = material_unit + + if material_total >= 0: + self.material_total = material_total + + if consume_material > 0: + self.last_material_used = consume_material + self.material_used += consume_material + if self.material_used > self.material_total: + self.material_total = self.material_used + def reset(self): self.filename = self.error_message = "" self.state = "standby" self.prev_pause_duration = self.last_epos = 0. - self.filament_used = self.total_duration = 0. + self.material_used = self.total_duration = 0. + self.material_total = self.last_material_used = 0. self.print_start_time = self.last_pause_time = None self.init_duration = 0. self.info_total_layer = None self.info_current_layer = None + def get_status(self, eventtime): time_paused = self.prev_pause_duration if self.print_start_time is not None: @@ -101,7 +142,7 @@ class PrintStats: # Accumulate filament if not paused self._update_filament_usage(eventtime) self.total_duration = eventtime - self.print_start_time - if self.filament_used < 0.0000001: + if self.material_used < 0.0000001: # Track duration prior to extrusion self.init_duration = self.total_duration - time_paused print_duration = self.total_duration - self.init_duration - time_paused @@ -109,11 +150,17 @@ class PrintStats: 'filename': self.filename, 'total_duration': self.total_duration, 'print_duration': print_duration, - 'filament_used': self.filament_used, + 'filament_used': self.material_used, # Deprecated to: material_used + 'material_type': self.material_type, # Material type, eg: filament + 'material_name': self.material_name, # Material name by user + 'material_unit': self.material_unit, # Material measure unit + 'material_total': self.material_total, # Total material in a print + 'material_used': self.material_used, # Amount of used material 'state': self.state, 'message': self.error_message, 'info': {'total_layer': self.info_total_layer, - 'current_layer': self.info_current_layer} + 'current_layer': self.info_current_layer, + 'last_material_used': self.last_material_used} } def load_config(config): From 23d3d7e53135b3a9928df1ab3df27626d40ac6be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Concei=C3=A7=C3=A3o?= Date: Mon, 29 Apr 2024 23:38:31 +0100 Subject: [PATCH 20/32] Also reset material type, name and unit on reset() --- klippy/extras/print_stats.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/klippy/extras/print_stats.py b/klippy/extras/print_stats.py index f67803843..1fb1f69e6 100644 --- a/klippy/extras/print_stats.py +++ b/klippy/extras/print_stats.py @@ -8,22 +8,23 @@ class PrintStats: def __init__(self, config): self.printer = config.get_printer() self.gcode_move = self.printer.load_object(config, 'gcode_move') + self.toolhead = None self.reactor = self.printer.get_reactor() self.material_type = '' - self.material_name = '' self.material_unit = '' self.reset() # Register events self.printer.register_event_handler("klippy:mcu_identify", - self._init_stats) + self._init_delayed_stats) # Register commands self.gcode = self.printer.lookup_object('gcode') self.gcode.register_command( "SET_PRINT_STATS_INFO", self.cmd_SET_PRINT_STATS_INFO, desc=self.cmd_SET_PRINT_STATS_INFO_help) - def _init_stats(self): - self.toolhead = self.printer.lookup_object('toolhead') + def _init_delayed_stats(self): + if self.toolhead is None: + self.toolhead = self.printer.lookup_object('toolhead') if self.toolhead: if self.toolhead.manufacturing_process == 'FDM': self.material_type = 'filament' @@ -127,11 +128,15 @@ class PrintStats: self.prev_pause_duration = self.last_epos = 0. self.material_used = self.total_duration = 0. self.material_total = self.last_material_used = 0. + self.material_name = '' self.print_start_time = self.last_pause_time = None self.init_duration = 0. self.info_total_layer = None self.info_current_layer = None + if self.toolhead is not None: + self._init_delayed_stats() + def get_status(self, eventtime): time_paused = self.prev_pause_duration if self.print_start_time is not None: From 19d47281b3267bf017bfe795a5f0f885b251eb28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Concei=C3=A7=C3=A3o?= Date: Thu, 2 May 2024 20:56:28 +0100 Subject: [PATCH 21/32] Allow to validate files with lower resolution but same pixel size --- docs/G-Codes.md | 10 ++++--- klippy/extras/msla_display.py | 53 +++++++++++++++++++++++++-------- klippy/extras/print_stats.py | 19 ++++++++++++ klippy/extras/virtual_sdcard.py | 4 +-- 4 files changed, 67 insertions(+), 19 deletions(-) diff --git a/docs/G-Codes.md b/docs/G-Codes.md index ffff52e52..b8625f8b9 100644 --- a/docs/G-Codes.md +++ b/docs/G-Codes.md @@ -344,10 +344,12 @@ The following commands are available when a [msla_display config section](Config_Reference.md#msla_display) is enabled. -- Validate the display information: - `MSLA_DISPLAY_VALIDATE RESOLUTION= PIXEL=` +- Validate print resolution and pixel size against the display information: + `MSLA_DISPLAY_VALIDATE RESOLUTION= PIXEL= STRICT=[0/1]` - `RESOLUTION:` Resolution size in pixels - `PIXEL`: Pixel size in millimeters + - `STRICT`: 0 = Prints if same or lower resolutions and same pixel size + 1 = Prints if same resolutions and pixel size - Tests the display response time: `MSLA_DISPLAY_RESPONSE_TIME AVG=[1]` - `AVG`: Number of samples to average the results - Test the display by showing full white and grey shades: `MSLA_DISPLAY_TEST @@ -364,8 +366,8 @@ is enabled. `MSLA_UVLED_RESPONSE_TIME TIME=[ms] OFFSET=[ms] TYPE=[0/1]` - `TIME`: Exposure time in milliseconds - `OFFSET`: Offset time from exposure time - - `TYPE`: <0> when using M1400 Sx and Px. (Faster response time). - <1> when using M1400 Sx, G4 Px, M1400 S0 (Slower response time) + - `TYPE`: 0 = When using M1400 Sx and Px. (Faster response time). + 1 = When using M1400 Sx, G4 Px, M1400 S0 (Slower response time) - Set the UV LED power: `M1400 S<0-255> P[ms]` - `S`: The LED Power (Non PWM LEDs will turn on from 1 to 255). - `P`: Time to wait in milliseconds when (S>0) before turn off. diff --git a/klippy/extras/msla_display.py b/klippy/extras/msla_display.py index 41e7060e5..f2eb6668f 100644 --- a/klippy/extras/msla_display.py +++ b/klippy/extras/msla_display.py @@ -13,7 +13,7 @@ import time import tempfile from pathlib import Path from PIL import Image -from . import framebuffer_display, pwm_tool, output_pin +from . import framebuffer_display, pwm_tool, pwm_cycle_time, output_pin CACHE_MIN_RAM = 256 # 256 MB @@ -105,6 +105,8 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): def get_status(self, eventtime): return {'display_busy': self.is_busy(), 'is_exposure': self.is_exposure(), + 'uvled_is_pwm': self.is_uvled_pwm(), + 'uvled_value_raw': self.uvled.last_value, 'uvled_value': self.uvled.last_value * 255.} def clear_cache(self): @@ -212,21 +214,28 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): cmd_MSLA_DISPLAY_VALIDATE_help = ("Validate the display against resolution " "and pixel parameters. Throw error if out" " of parameters.") + def cmd_MSLA_DISPLAY_VALIDATE(self, gcmd): """ - Layer images are universal within same resolution and pixel pitch. - Other printers with same resolution and pixel pitch can print same file. + Layer images are universal within same pixel pitch, but it also must fit + within the LCD area. + Other printers with same or higher resolution and same pixel pitch can + print same file. This command ensure print only continue if requirements are meet. Syntax: MSLA_DISPLAY_VALIDATE RESOLUTION= PIXEL= + STRICT=[0/1] RESOLUTION: Machine LCD resolution PIXEL: Machine LCD pixel pitch + STRICT: 0 = Prints if same or lower resolutions + 1 = Prints if same resolutions @param gcmd: @return: """ resolution = gcmd.get('RESOLUTION') pixel_size = gcmd.get('PIXEL') + strict = gcmd.get_int('STRICT', 0, 0, 1) resolution_split = resolution.split(',', 1) if len(resolution_split) < 2: @@ -243,13 +252,26 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): except: raise gcmd.error(f"The resolution y must be an integer.") - if resolution_x != self.resolution_x: - raise gcmd.error(f"The resolution X of {resolution_x} is invalid. " - f"Should be {self.resolution_x}.") + if strict: + if resolution_x > self.resolution_x: + raise gcmd.error( + f"The resolution X of {resolution_x} is invalid. " + f"Should be equal to {self.resolution_x}.") - if resolution_y != self.resolution_y: - raise gcmd.error(f"The resolution Y of {resolution_y} is invalid. " - f"Should be {self.resolution_y}.") + if resolution_y > self.resolution_y: + raise gcmd.error( + f"The resolution Y of {resolution_y} is invalid. " + f"Should be equal to {self.resolution_y}.") + else: + if resolution_x > self.resolution_x: + raise gcmd.error(f"The resolution X of {resolution_x} is " + f"invalid. Should be less or equal to " + f"{self.resolution_x}.") + + if resolution_y > self.resolution_y: + raise gcmd.error(f"The resolution Y of {resolution_y} is " + f"invalid. Should be less or equal to " + f"{self.resolution_y}.") pixel_size_split = pixel_size.split(',', 1) if len(pixel_size_split) < 2: @@ -313,10 +335,10 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): self.clear_buffer() gcmd.respond_raw(f"Test finished.") - cmd_MSLA_DISPLAY_RESPONSE_TIME_help = ("Sends a buffer to display and test " "it response time to complete the " "render.") + def cmd_MSLA_DISPLAY_RESPONSE_TIME(self, gcmd): """ Sends a buffer to display and test it response time @@ -433,9 +455,7 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): the display. @return: """ - if (self.uvled is pwm_tool.PrinterOutputPin or ( - self.uvled is output_pin.PrinterOutputPin - and self.uvled.is_pwm)): + if self.is_uvled_pwm(): value /= 255.0 else: value = 1 if value else 0 @@ -460,6 +480,12 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): lambda print_time: self.uvled._set_pin(print_time, 0)) toolhead.wait_moves() + def is_uvled_pwm(self): + return (isinstance(self.uvled, (pwm_tool.PrinterOutputPin, + pwm_cycle_time.PrinterOutputPWMCycle)) + or (isinstance(self.uvled, output_pin.PrinterOutputPin) + and self.uvled.is_pwm)) + def set_uvled_on(self, delay=0): """ Turns the UV LED on with max power @@ -571,6 +597,7 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): """ self.set_uvled(0) + class BufferCache: def __init__(self, path, data): self.path = path diff --git a/klippy/extras/print_stats.py b/klippy/extras/print_stats.py index 1fb1f69e6..5e18800d4 100644 --- a/klippy/extras/print_stats.py +++ b/klippy/extras/print_stats.py @@ -85,6 +85,25 @@ class PrintStats: cmd_SET_PRINT_STATS_INFO_help = "Pass slicer info like layer act and " \ "total to klipper" def cmd_SET_PRINT_STATS_INFO(self, gcmd): + """ + Sets print stats info + + Syntax: SET_PRINT_STATS_INFO TOTAL_LAYER=[count] + CURRENT_LAYER=[number] + MATERIAL_NAME=["name"] + MATERIAL_UNIT=[unit] + MATERIAL_TOTAL=[total] + CONSUME_MATERIAL=[amount] + + TOTAL_LAYER: Total layer count + CURRENT_LAYER: Current printing layer number + MATERIAL_NAME: Name of the material being used + MATERIAL_UNIT: Material unit + MATERIAL_TOTAL: Total material this print will consume + CONSUME_MATERIAL: Consume material and increment the used material + @param gcmd: + @return: + """ total_layer = gcmd.get_int("TOTAL_LAYER", self.info_total_layer, \ minval=0) current_layer = gcmd.get_int("CURRENT_LAYER", self.info_current_layer, \ diff --git a/klippy/extras/virtual_sdcard.py b/klippy/extras/virtual_sdcard.py index c46805341..cbc445741 100644 --- a/klippy/extras/virtual_sdcard.py +++ b/klippy/extras/virtual_sdcard.py @@ -82,7 +82,7 @@ class VirtualSD: if self.work_timer is None: return False, "" return True, "sd_pos=%d" % (self.file_position,) - def get_archive_files(self, path): + def get_archive_entries(self, path): if path.endswith('.zip'): try: with zipfile.ZipFile(path, "r") as file: @@ -110,7 +110,7 @@ class VirtualSD: r_path = full_path[len(self.sdcard_dirname) + 1:] if ext in VALID_ARCHIVE_EXTS: - entries = self.get_archive_files(full_path) + entries = self.get_archive_entries(full_path) # Support only 1 gcode file as if there's more we are # unable to guess which to print From 4474722f41d780832dca00ff915eea1e053e3bda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Concei=C3=A7=C3=A3o?= Date: Thu, 2 May 2024 20:56:45 +0100 Subject: [PATCH 22/32] Implement LCD menus --- klippy/extras/display/display.cfg | 52 ++++++++++++++++++++++++++----- klippy/extras/display/menu.cfg | 36 +++++++++++++++++++++ 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/klippy/extras/display/display.cfg b/klippy/extras/display/display.cfg index 042c32727..7b04b00a1 100644 --- a/klippy/extras/display/display.cfg +++ b/klippy/extras/display/display.cfg @@ -9,7 +9,6 @@ ###################################################################### # Helper macros for showing common screen values ###################################################################### - [display_template _heater_temperature] param_heater_name: "extruder" text: @@ -65,13 +64,19 @@ text: Ready {% endif %} +[display_template _uvled] +text: + {% if 'msla_display' in printer %} + {% if printer.msla_display.is_exposure %} + ~uvled~ + {% endif %} + {% endif %} ###################################################################### # Default 16x4 display ###################################################################### - [display_data _default_16x4 extruder] -position: 0, 0 +position: 1, 0 text: {% set active_extruder = printer.toolhead.extruder %} { render("_heater_temperature", param_heater_name=active_extruder) } @@ -105,11 +110,13 @@ text: { "%6s" % (render("_printing_time").strip(),) } position: 3, 0 text: { render("_print_status") } +[display_data _default_16x4 uvled] +position: 0, 0 +text: { render("_uvled") } ###################################################################### # Alternative 16x4 layout for multi-extruders ###################################################################### - [display_data _multiextruder_16x4 extruder] position: 0, 0 text: { render("_heater_temperature", param_heater_name="extruder") } @@ -145,7 +152,6 @@ text: { render("_print_status") } ###################################################################### # Default 20x4 display ###################################################################### - [display_data _default_20x4 extruder] position: 0, 0 text: { render("_heater_temperature", param_heater_name="extruder") } @@ -191,11 +197,13 @@ text: position: 3, 0 text: { render("_print_status") } +[display_data _default_20x4 uvled] +position: 1, 0 +text: { render("_uvled") } ###################################################################### # Default 16x4 glyphs ###################################################################### - [display_glyph extruder] data: ................ @@ -329,6 +337,25 @@ data: ................ ................ +[display_glyph uvled] +data: + ................ + ...*...*...*.... + ....*..*..*..... + .....*.*.*...... + ......***....... + ......*.*....... + ......***....... + .....*.*.*...... + ....*..*..*..... + ...*...*...*.... + ................ + .....******..... + ...**********... + .**************. + .**************. + ................ + # In addition to the above glyphs, 16x4 displays also have the # following hard-coded single character glyphs: right_arrow, degrees. @@ -336,7 +363,6 @@ data: ###################################################################### # Default 20x4 glyphs ###################################################################### - [display_glyph extruder] hd44780_slot: 0 hd44780_data: @@ -457,5 +483,17 @@ hd44780_data: ***** ..... +[display_glyph uvled] +hd44780_slot: 0 +hd44780_data: + ***** + ..... + *.*.* + *.*.* + *.*.* + ..... + .***. + ***** + # In addition to the above glyphs, 20x4 displays also have the # following hard-coded glyphs: right_arrow. diff --git a/klippy/extras/display/menu.cfg b/klippy/extras/display/menu.cfg index b0df9fc50..85a0a85d6 100644 --- a/klippy/extras/display/menu.cfg +++ b/klippy/extras/display/menu.cfg @@ -30,6 +30,8 @@ # + Steppers off # + Fan: OFF # + Fan speed: 000% +# + UVLED: OFF +# + UVLED pwr: 000% # + Lights: OFF # + Lights: 000% # + Move 10mm @@ -75,6 +77,7 @@ # + Restart # + Restart host # + Restart FW +# + Display Test # + PID tuning # + Tune Hotend PID # + Tune Hotbed PID @@ -113,6 +116,7 @@ gcode: [menu __main __tune __flow] type: input +enable: {('extruder' in printer) and ('extruder' in printer.heaters.available_heaters)} name: Flow: {'%3d' % (menu.input*100)}% input: {printer.gcode_move.extrude_factor} input_min: 0.01 @@ -256,6 +260,7 @@ gcode: BED_MESH_CALIBRATE [menu __main __control __disable] type: command +enable: {not printer.idle_timeout.state == "Printing"} name: Steppers off gcode: M84 @@ -283,6 +288,28 @@ input_step: 0.01 gcode: M106 S{'%d' % (menu.input*255)} +[menu __main __control __uvledoff] +type: input +enable: {'msla_display' in printer and not printer.idle_timeout.state == "Printing"} +name: UVLED: {'ON ' if menu.input else 'OFF'} +input: {printer.msla_display.uvled_value_raw} +input_min: 0 +input_max: 1 +input_step: 1 +gcode: + M1400 S{255 if menu.input else 0} + +[menu __main __control __uvled] +type: input +enable: {'msla_display' in printer and not printer.idle_timeout.state == "Printing" and printer.msla_display.uvled_is_pwm} +name: UVLED pwr: {'%3d' % (menu.input*100)}% +input: {printer.msla_display.uvled_value_raw} +input_min: 0 +input_max: 1 +input_step: 0.01 +gcode: + M1400 S{'%d' % (menu.input*255)} + [menu __main __control __caselightonoff] type: input enable: {'output_pin caselight' in printer} @@ -489,6 +516,7 @@ gcode: [menu __main __temp] type: list name: Temperature +enable: {('extruder' in printer) or ('heater_bed' in printer)} [menu __main __temp __hotend0_target] type: input @@ -597,6 +625,7 @@ gcode: M140 S0 [menu __main __filament] type: list name: Filament +enable: {'extruder' in printer} [menu __main __filament __hotend0_target] type: input @@ -682,9 +711,16 @@ enable: {not printer.idle_timeout.state == "Printing"} name: Restart FW gcode: FIRMWARE_RESTART +[menu __main __setup __display_test] +type: command +name: Display test +enable: {'msla_display' in printer and (not printer.idle_timeout.state == "Printing")} +gcode: MSLA_DISPLAY_TEST + [menu __main __setup __tuning] type: list name: PID tuning +enable: {('extruder' in printer) or ('heater_bed' in printer)} [menu __main __setup __tuning __hotend_pid_tuning] type: command From f7beada4f716ebe5c2cc2263465219f57c01362f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Concei=C3=A7=C3=A3o?= Date: Thu, 2 May 2024 21:23:49 +0100 Subject: [PATCH 23/32] Fix extruder position --- config/sample_msla_display.cfg | 5 ++++- klippy/extras/display/display.cfg | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/config/sample_msla_display.cfg b/config/sample_msla_display.cfg index 734e3610d..2795f0e2a 100644 --- a/config/sample_msla_display.cfg +++ b/config/sample_msla_display.cfg @@ -9,4 +9,7 @@ resolution_x: 3840 resolution_y: 2400 pixel_width: 0.05 pixel_height: 0.05 -uvled_output_pin_name: msla_uvled \ No newline at end of file +uvled_output_pin_name: msla_uvled + +[printer] +manufacturing_process: mSLA \ No newline at end of file diff --git a/klippy/extras/display/display.cfg b/klippy/extras/display/display.cfg index 7b04b00a1..a6b5efbb2 100644 --- a/klippy/extras/display/display.cfg +++ b/klippy/extras/display/display.cfg @@ -76,7 +76,7 @@ text: # Default 16x4 display ###################################################################### [display_data _default_16x4 extruder] -position: 1, 0 +position: 0, 0 text: {% set active_extruder = printer.toolhead.extruder %} { render("_heater_temperature", param_heater_name=active_extruder) } @@ -111,7 +111,7 @@ position: 3, 0 text: { render("_print_status") } [display_data _default_16x4 uvled] -position: 0, 0 +position: 1, 0 text: { render("_uvled") } ###################################################################### From d086b3071c7441d9b7af640824bf0f2ea6af18b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Concei=C3=A7=C3=A3o?= Date: Fri, 3 May 2024 02:27:37 +0100 Subject: [PATCH 24/32] Refactorings - Add M1450 to clear the screen - Change M6054 to M1451 and force the F param --- docs/G-Codes.md | 20 +++--- klippy/extras/display/menu.cfg | 6 +- klippy/extras/msla_display.py | 126 +++++++++++++++++++-------------- 3 files changed, 87 insertions(+), 65 deletions(-) diff --git a/docs/G-Codes.md b/docs/G-Codes.md index b8625f8b9..22cbd2199 100644 --- a/docs/G-Codes.md +++ b/docs/G-Codes.md @@ -355,22 +355,22 @@ is enabled. - Test the display by showing full white and grey shades: `MSLA_DISPLAY_TEST DELAY=[ms]` - `DELAY`: Time in milliseconds between tests -- Display image: `M6054 F<"image.png"> C[0/1] W[0/1]` +- Display clear: `M1450` +- Display image: `M1451 F<"image.png"> O[n] C[0/1] W[0/1]` - `F`: Image file to display, when printing this file is relative to the base path of the print gcode - - `O`: Positive offset from start position of the buffer - - `C`: Clear the remaining buffer, 0=No, 1=Yes, 2=Auto. (int) Default: 2 - - `W`: Wait for the render to complete -- Display clear: `M6054` + - `O`: Positive offset from start position of the buffer. Default: 0 + - `C`: Clear the remaining buffer, 0=No, 1=Yes, 2=Auto. Default: 2 + - `W`: Wait for the render to complete. Default: 0 - Tests the UV LED response time: `MSLA_UVLED_RESPONSE_TIME TIME=[ms] OFFSET=[ms] TYPE=[0/1]` - `TIME`: Exposure time in milliseconds - - `OFFSET`: Offset time from exposure time - - `TYPE`: 0 = When using M1400 Sx and Px. (Faster response time). + - `OFFSET`: Offset time from exposure time. Default: 0 + - `TYPE`: 0 = When using M1400 Sx and Px. (Faster response time). (Default) 1 = When using M1400 Sx, G4 Px, M1400 S0 (Slower response time) -- Set the UV LED power: `M1400 S<0-255> P[ms]` - - `S`: The LED Power (Non PWM LEDs will turn on from 1 to 255). - - `P`: Time to wait in milliseconds when (S>0) before turn off. +- Set the UV LED power: `M1400 S[0-255] P[ms]` + - `S`: The LED Power (Non PWM LEDs will turn on from 1 to 255). Default: 255 + - `P`: Time to wait in milliseconds when (S>0) before turn off. Default: 0 - Turn off the UV LED: `M1401` ### [dual_carriage] diff --git a/klippy/extras/display/menu.cfg b/klippy/extras/display/menu.cfg index 85a0a85d6..62d6c7009 100644 --- a/klippy/extras/display/menu.cfg +++ b/klippy/extras/display/menu.cfg @@ -30,8 +30,8 @@ # + Steppers off # + Fan: OFF # + Fan speed: 000% -# + UVLED: OFF -# + UVLED pwr: 000% +# + UVLED: OFF [mSLA only] +# + UVLED pwr: 000% [mSLA only] # + Lights: OFF # + Lights: 000% # + Move 10mm @@ -77,7 +77,7 @@ # + Restart # + Restart host # + Restart FW -# + Display Test +# + Display Test [mSLA only] # + PID tuning # + Tune Hotend PID # + Tune Hotbed PID diff --git a/klippy/extras/msla_display.py b/klippy/extras/msla_display.py index f2eb6668f..70f21a412 100644 --- a/klippy/extras/msla_display.py +++ b/klippy/extras/msla_display.py @@ -74,7 +74,7 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): # Variables self._sd = self.printer.lookup_object("virtual_sdcard") - self._m6054_file_regex = re.compile(r'F(.+[.](png|jpg|jpeg|bmp|gif))') + self._M1451_file_regex = re.compile(r'F(.+[.](png|jpg|jpeg|bmp|gif))') # Events self.printer.register_event_handler("virtual_sdcard:reset_file", @@ -91,15 +91,17 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): gcode.register_command("MSLA_DISPLAY_RESPONSE_TIME", self.cmd_MSLA_DISPLAY_RESPONSE_TIME, desc=self.cmd_MSLA_DISPLAY_RESPONSE_TIME_help) - gcode.register_command('M6054', self.cmd_M6054, - desc=self.cmd_M6054_help) + gcode.register_command('M1450', self.cmd_M1450, # Display clear + desc=self.cmd_M1451_help) + gcode.register_command('M1451', self.cmd_M1451, # Display image + desc=self.cmd_M1451_help) gcode.register_command("MSLA_UVLED_RESPONSE_TIME", self.cmd_MSLA_UVLED_RESPONSE_TIME, desc=self.cmd_MSLA_UVLED_RESPONSE_TIME_help) - gcode.register_command('M1400', self.cmd_M1400, + gcode.register_command('M1400', self.cmd_M1400, # UVLED SET desc=self.cmd_M1400_help) - gcode.register_command('M1401', self.cmd_M1401, + gcode.register_command('M1401', self.cmd_M1401, # UVLED OFF desc=self.cmd_M1401_help) def get_status(self, eventtime): @@ -160,7 +162,7 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): def _get_cache_index(self, path) -> int: """ - Gets the index of the cache position on the list given an path + Gets the index of the cache position on the list given a path @param path: Path to seek @return: Index of the cache on the list, otherwise -1 """ @@ -171,7 +173,7 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): def _get_image_buffercache(self, gcmd, path): """ - Gets a image buffer from cache if available, otherwise it creates the + Gets an image buffer from cache if available, otherwise it creates the buffer from the path image @param gcmd: @param path: Image path @@ -182,7 +184,8 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): if current_filepath is not None and path[0] != '/': path = os.path.join(os.path.dirname(current_filepath), path) if not os.path.isfile(path): - raise gcmd.error(f"M6054: The file '{path}' does not exists.") + raise (gcmd.error("%s: The file '%s' does not exists.") % + (gcmd.get_command(), path)) if self.buffer_cache_count > 0: self._wait_cache_thread() # May be worth to wait if streaming @@ -200,7 +203,8 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): return cache.data if not os.path.isfile(path): - raise gcmd.error(f"M6054: The file '{path}' does not exists.") + raise (gcmd.error("%s: The file '%s' does not exists.") % + (gcmd.get_command(), path)) di = self.get_image_buffer(path) if current_filepath and self._can_cache(): @@ -390,15 +394,34 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): gcmd.respond_raw('Clear time: %fms (%d samples)' % (round(time_sum * 1000 / avg, 6), avg)) - cmd_M6054_help = ("Reads an image from a path and display it on " - "the display. No parameter clears the display.") + cmd_M1450_help = ("Clears the display with all black pixels " + "(Fill with zeros)") - def cmd_M6054(self, gcmd): + def cmd_M1450(self, gcmd): + """ + Clears the display with all black pixels (Fill with zeros) + + Syntax: M1450 W[0/1] + W: Wait for buffer to complete the transfer. (int) + @param gcmd: + """ + wait_thread = gcmd.get_int('W', 0, 0, 1) + + toolhead = self.printer.lookup_object('toolhead') + if self.is_exposure(): + toolhead.wait_moves() + self.clear_buffer_threaded(True) + else: + self.clear_buffer_threaded(wait_thread) + + cmd_M1451_help = ("Reads an image from a path and display it on " + "the display.") + + def cmd_M1451(self, gcmd): """ Display an image from a path and display it on the main display. - No parameter clears the display. - Syntax: M6054 F<"file.png"> O[x] C[0/1/3] W[0/1] + Syntax: M1451 F<"file.png"> O[x] C[0/1/3] W[0/1] F: File path eg: "1.png". quotes are optional. (str) O: Sets a positive offset from start position of the buffer. (int) C: Clear the remaining buffer, 0=No, 1=Yes, 2=Auto. (int) Default: 2 @@ -410,34 +433,31 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): framebuffer_display.CLEAR_AUTO_FLAG) wait_thread = gcmd.get_int('W', 0, 0, 1) - toolhead = self.printer.lookup_object('toolhead') params = gcmd.get_raw_command_parameters().strip() - match = self._m6054_file_regex.search(params) - if match: - path = match.group(1).strip(' "\'') - path = os.path.normpath(os.path.expanduser(path)) - di = self._get_image_buffercache(gcmd, path) - max_offset = self.get_max_offset( - di['size'], di['stride'], di['height']) - offset = gcmd.get_int('O', 0, 0, max_offset) - if self.is_exposure(): - # LED is ON, sync to prevent image change while exposure - toolhead.wait_moves() - self.write_buffer_threaded(di['buffer'], di['stride'], offset, - clear_flag) - else: - self.write_buffer_threaded(di['buffer'], di['stride'], offset, - clear_flag) + if 'F' not in params: + raise gcmd.error(f"Error on '{gcmd.get_command()}': missing F") + + toolhead = self.printer.lookup_object('toolhead') + match = self._M1451_file_regex.search(params) + if not match: + raise gcmd.error(f"Error on '{gcmd.get_command()}': The F parameter" + f" is malformed. Use a proper image file.") + + path = match.group(1).strip(' "\'') + path = os.path.normpath(os.path.expanduser(path)) + di = self._get_image_buffercache(gcmd, path) + + max_offset = self.get_max_offset(di['size'], di['stride'], di['height']) + offset = gcmd.get_int('O', 0, 0, max_offset) + if self.is_exposure(): + # LED is ON, sync to prevent image change while exposure + toolhead.wait_moves() + self.write_buffer_threaded(di['buffer'], di['stride'], offset, + clear_flag, True) else: - if 'F' in params: - raise gcmd.error('M6054: The F parameter is malformed.' - 'Use a proper image file.') - if self.is_exposure(): - toolhead.wait_moves() - self.clear_buffer_threaded(True) - else: - self.clear_buffer_threaded(wait_thread) + self.write_buffer_threaded(di['buffer'], di['stride'], offset, + clear_flag, wait_thread) def is_exposure(self) -> bool: """ @@ -446,6 +466,15 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): """ return self.uvled.last_value > 0 + def is_uvled_pwm(self) -> bool: + """ + True if UV LED is configured as PWM pin, otherwise False + """ + return (isinstance(self.uvled, (pwm_tool.PrinterOutputPin, + pwm_cycle_time.PrinterOutputPWMCycle)) + or (isinstance(self.uvled, output_pin.PrinterOutputPin) + and self.uvled.is_pwm)) + def set_uvled(self, value, delay=0): """ Turns the UV LED on or off. @@ -480,12 +509,6 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): lambda print_time: self.uvled._set_pin(print_time, 0)) toolhead.wait_moves() - def is_uvled_pwm(self): - return (isinstance(self.uvled, (pwm_tool.PrinterOutputPin, - pwm_cycle_time.PrinterOutputPWMCycle)) - or (isinstance(self.uvled, output_pin.PrinterOutputPin) - and self.uvled.is_pwm)) - def set_uvled_on(self, delay=0): """ Turns the UV LED on with max power @@ -499,7 +522,6 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): def set_uvled_off(self): """ Turns the UV LED off - @return: """ self.set_uvled(0) @@ -527,9 +549,9 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): delay = gcmd.get_int('TIME', 3000, minval=500, maxval=5000) / 1000. offset = gcmd.get_int('OFFSET', 0, minval=-1000, maxval=0) / 1000. - type = gcmd.get_int('TYPE', 0, minval=0, maxval=1) + etype = gcmd.get_int('TYPE', 0, minval=0, maxval=1) - if type == 0: + if etype == 0: offset -= 0.2 toolhead = self.printer.lookup_object('toolhead') @@ -552,7 +574,7 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): exposure_time = round(final_time - exposure_time, 4) time_total = round(final_time - time_total, 4) - if type == 0: + if etype == 0: offset += 0.2 calculated_offset = max(0, round((exposure_time - delay + offset) @@ -577,12 +599,12 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): lies in the range of 100–400 nm, and is further subdivided into UVA (315–400 nm), UVB (280–315 nm), and UVC (100–280 nm). - Syntax: M1400 S<0-255> P[ms] - S: LED Power (Non PWM LEDs will turn on from 1 to 255). (int) + Syntax: M1400 S[0-255] P[ms] + S: LED Power (Non PWM LEDs will turn on from 1 to 255). (float) P: Time to wait in milliseconds when (S>0) before turn off. (int) @param gcmd: """ - value = gcmd.get_float('S', minval=0., maxval=255.) + value = gcmd.get_float('S', 255., minval=0., maxval=255.) delay = gcmd.get_float('P', 0, above=0.) / 1000. self.set_uvled(value, delay) From f278f6e7180ecbcc1fde8754600d8e63c2430f7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Concei=C3=A7=C3=A3o?= Date: Sun, 5 May 2024 19:18:44 +0100 Subject: [PATCH 25/32] Fix Line has trailing spaces --- config/sample_msla_display.cfg | 2 +- docs/Config_Reference.md | 16 ++++++++-------- docs/G-Codes.md | 12 ++++++------ klippy/extras/framebuffer_display.py | 6 +++--- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/config/sample_msla_display.cfg b/config/sample_msla_display.cfg index 2795f0e2a..67a7c9740 100644 --- a/config/sample_msla_display.cfg +++ b/config/sample_msla_display.cfg @@ -12,4 +12,4 @@ pixel_height: 0.05 uvled_output_pin_name: msla_uvled [printer] -manufacturing_process: mSLA \ No newline at end of file +manufacturing_process: mSLA diff --git a/docs/Config_Reference.md b/docs/Config_Reference.md index dac2d49b9..191a924fb 100644 --- a/docs/Config_Reference.md +++ b/docs/Config_Reference.md @@ -4395,21 +4395,21 @@ pixel_format: RGB # Default: RGB framebuffer_index: 0 # The framebuffer index, this maps to /dev/fb -resolution_x: 1440 +resolution_x: 1440 resolution_y: 2560 # The display resolution, these values are compared and validated against the # the framebuffer device information. pixel_width: 0.01575 pixel_height: 0.04725 -# The pixel pitch/size in millimeters, these values are used to calculate +# The pixel pitch/size in millimeters, these values are used to calculate # the display size accordingly. ``` ### [msla_display] -Support for a mSLA display that will be used to display images and cure resin -by lit a UV LED under the display pixels. -For the complete configuration follow the +Support for a mSLA display that will be used to display images and cure resin +by lit a UV LED under the display pixels. +For the complete configuration follow the [framebuffer_display](Config_Reference.md#framebuffer_display) See the [command reference](G-Codes.md#msla_display) for more information. @@ -4421,11 +4421,11 @@ buffer_cache_count: 1 # Number of images to cache while printing, cached buffers cut the processing # time and render the image faster into the display. Default: 1 uvled_output_pin_name: msla_uvled -# The configurated UV LED [output_pin msla_uvled] or [pwm_tool msla_uvled], -# either section must exist and be configured to attach a UV LED to this +# The configurated UV LED [output_pin msla_uvled] or [pwm_tool msla_uvled], +# either section must exist and be configured to attach a UV LED to this # display. Default: msla_uvled uvled_response_delay: 0 -# The UV LED response delay in milliseconds, often this is the time the +# The UV LED response delay in milliseconds, often this is the time the # micro-controller takes to switch the UV LED pin. This offset will subtract # to a defined exposure time when issue the M1400 Sx Px command. Default: 0 ``` diff --git a/docs/G-Codes.md b/docs/G-Codes.md index 22cbd2199..68790f79d 100644 --- a/docs/G-Codes.md +++ b/docs/G-Codes.md @@ -325,13 +325,13 @@ Also provided is the following extended G-Code command: ### [framebuffer_display] The following commands are available when a -[framebuffer_display config section](Config_Reference.md#framebuffer_display) +[framebuffer_display config section](Config_Reference.md#framebuffer_display) is enabled. - Clear the framebuffer (fill with zeros): `FRAMEBUFFER_CLEAR DEVICE= WAIT=[0/1]` - Send image: `FRAMEBUFFER_DRAW_IMAGE DEVICE= PATH= CLEAR=[0/1/2] - OFFSET=[n] WAIT=[0/1]` + OFFSET=[n] WAIT=[0/1]` - `DEVICE`: Configured device name in [framebuffer_display name] - `PATH`: Absolute image path - `OFFSET`: Offset from buffer start to write the image @@ -341,7 +341,7 @@ is enabled. ### [msla_display] The following commands are available when a -[msla_display config section](Config_Reference.md#msla_display) +[msla_display config section](Config_Reference.md#msla_display) is enabled. - Validate print resolution and pixel size against the display information: @@ -352,12 +352,12 @@ is enabled. 1 = Prints if same resolutions and pixel size - Tests the display response time: `MSLA_DISPLAY_RESPONSE_TIME AVG=[1]` - `AVG`: Number of samples to average the results -- Test the display by showing full white and grey shades: `MSLA_DISPLAY_TEST +- Test the display by showing full white and grey shades: `MSLA_DISPLAY_TEST DELAY=[ms]` - - `DELAY`: Time in milliseconds between tests + - `DELAY`: Time in milliseconds between tests - Display clear: `M1450` - Display image: `M1451 F<"image.png"> O[n] C[0/1] W[0/1]` - - `F`: Image file to display, when printing this file is relative to the + - `F`: Image file to display, when printing this file is relative to the base path of the print gcode - `O`: Positive offset from start position of the buffer. Default: 0 - `C`: Clear the remaining buffer, 0=No, 1=Yes, 2=Auto. Default: 2 diff --git a/klippy/extras/framebuffer_display.py b/klippy/extras/framebuffer_display.py index 7dcab5517..e4063d043 100644 --- a/klippy/extras/framebuffer_display.py +++ b/klippy/extras/framebuffer_display.py @@ -467,9 +467,9 @@ class FramebufferDisplayWrapper(FramebufferDisplay): super().__init__(config) device_name = config.get_name().split()[1] - self.gcode.register_mux_command("FRAMEBUFFER_CLEAR", "DEVICE", device_name, - self.cmd_FRAMEBUFFER_CLEAR, - desc=self.cmd_FRAMEBUFFER_CLEAR_help) + self.gcode.register_mux_command("FRAMEBUFFER_CLEAR", "DEVICE", + device_name, self.cmd_FRAMEBUFFER_CLEAR, + desc=self.cmd_FRAMEBUFFER_CLEAR_help) self.gcode.register_mux_command("FRAMEBUFFER_SEND_IMAGE", "DEVICE", device_name, self.cmd_FRAMEBUFFER_DRAW_IMAGE, desc=self.cmd_FRAMEBUFFER_DRAW_IMAGE_help) From c878669b34ac2e1d2655bca0ffb1869261661bd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Concei=C3=A7=C3=A3o?= Date: Sun, 5 May 2024 19:33:39 +0100 Subject: [PATCH 26/32] Update msla_display.py --- klippy/extras/msla_display.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/klippy/extras/msla_display.py b/klippy/extras/msla_display.py index 70f21a412..e419c673e 100644 --- a/klippy/extras/msla_display.py +++ b/klippy/extras/msla_display.py @@ -67,8 +67,8 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): self.uvled_output_pin_name, None) if self.uvled is None: - msg = (f"The [output_pin %s] or [pwm_tool %s] was not found." % - (self.uvled_output_pin_name, self.uvled_output_pin_name)) + msg = f"The [output_pin %s] or [pwm_tool %s] was not found." % ( + self.uvled_output_pin_name, self.uvled_output_pin_name) logging.exception(msg) raise config.error(msg) From d5c529eff63e67375c2ee8b20782791bedccacbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Concei=C3=A7=C3=A3o?= Date: Sun, 5 May 2024 23:13:41 +0100 Subject: [PATCH 27/32] Compability with python2 --- klippy/extras/framebuffer_display.py | 98 +++++++++---------- klippy/extras/msla_display.py | 135 ++++++++++++++------------- klippy/extras/virtual_sdcard.py | 16 ++-- 3 files changed, 128 insertions(+), 121 deletions(-) diff --git a/klippy/extras/framebuffer_display.py b/klippy/extras/framebuffer_display.py index e4063d043..06640aa6c 100644 --- a/klippy/extras/framebuffer_display.py +++ b/klippy/extras/framebuffer_display.py @@ -9,6 +9,7 @@ import math import re import os import mmap +import struct from PIL import Image import threading @@ -62,7 +63,7 @@ class FramebufferDisplay: self.display_width ** 2 + self.display_height ** 2) / 25.4, 2) # get modes width and height - with open(f"/sys/class/graphics/fb{self.framebuffer_index}/modes", + with open("/sys/class/graphics/fb%d/modes" % self.framebuffer_index, "r") as f: modes = f.read() match = re.search(r"(\d+)x(\d+)", modes) @@ -71,42 +72,41 @@ class FramebufferDisplay: self.fb_modes_height = int(match.group(2)) else: msg = ('Unable to extract "modes" information from famebuffer ' - f"device fb{self.framebuffer_index}.") + "device fb%d." % self.framebuffer_index) logging.exception(msg) raise config.error(msg) # get virtual width and height with open( - f"/sys/class/graphics/fb{self.framebuffer_index}/virtual_size", - "r") as f: + "/sys/class/graphics/fb%d/virtual_size" + % self.framebuffer_index, "r") as f: virtual_size = f.read() width_string, height_string = virtual_size.split(',') self.fb_virtual_width = int(width_string) # width self.fb_virtual_height = int(height_string) # height # get stride - with open(f"/sys/class/graphics/fb{self.framebuffer_index}/stride", + with open("/sys/class/graphics/fb%d/stride" % self.framebuffer_index, "r") as f: self.fb_stride = int(f.read()) # get bits per pixel with open( - f"/sys/class/graphics/fb{self.framebuffer_index}/bits_per_pixel" - , "r") as f: + "/sys/class/graphics/fb%d/bits_per_pixel" + % self.framebuffer_index, "r") as f: self.fb_bits_per_pixel = int(f.read()) # Check if configured resolutions match framebuffer information if self.resolution_x not in (self.fb_stride, self.fb_modes_width): - msg = (f"The configured resolution_x of {self.resolution_x} " - f"does not match any of the framebuffer stride " - f"of ({self.fb_stride}) nor " - f"the modes of ({self.fb_modes_width}).") + msg = ("The configured resolution_x of %d does not match any of " + "the framebuffer stride of (%d) nor the modes of (%d)." + % (self.resolution_x, self.fb_stride, self.fb_modes_width)) logging.exception(msg) raise config.error(msg) if self.resolution_y != self.fb_modes_height: - msg = (f"The configured resolution_y of {self.resolution_y} does " - f"not match the framebuffer " - f"modes of ({self.fb_modes_height}).") + msg = ("The configured resolution_y of %d does not match the " + "framebuffer modes of (%d)." + % (self.resolution_y, self.fb_modes_height)) logging.exception(msg) raise config.error(msg) @@ -125,7 +125,7 @@ class FramebufferDisplay: self.clear_buffer_threaded() - def is_busy(self) -> bool: + def is_busy(self): """ Checks if the device is busy. @return: True if the device is busy, false otherwise. @@ -134,7 +134,7 @@ class FramebufferDisplay: (self._framebuffer_thread is not None and self._framebuffer_thread.is_alive())) - def wait_framebuffer_thread(self, timeout=None) -> bool: + def wait_framebuffer_thread(self, timeout=None): """ Wait for the framebuffer thread to terminate (Finish writes). @param timeout: Timeout time to wait for framebuffer thread to terminate @@ -147,12 +147,12 @@ class FramebufferDisplay: return True return False - def get_framebuffer_path(self) -> str: + def get_framebuffer_path(self): """ Gets the framebuffer device path associated with this object. @return: /dev/fb[i] """ - return f"/dev/fb{self.framebuffer_index}" + return "/dev/fb%d" % self.framebuffer_index def get_max_offset(self, buffer_size, stride, height=-1): """ @@ -167,7 +167,7 @@ class FramebufferDisplay: height = buffer_size // stride return max(self.fb_maxsize - self.fb_stride * (height - 1) - stride, 0) - def get_position_from_xy(self, x, y) -> int: + def get_position_from_xy(self, x, y): """ Gets the starting point in the buffer given an x and y coordinate @param x: X coordinate @@ -180,7 +180,7 @@ class FramebufferDisplay: raise self.gcode.error(msg) if x >= self.resolution_x: - msg = f"The x value must be less than {self.resolution_x}" + msg = "The x value must be less than %d" % self.resolution_x logging.exception(msg) raise self.gcode.error(msg) @@ -190,13 +190,13 @@ class FramebufferDisplay: raise self.gcode.error(msg) if y >= self.resolution_y: - msg = f"The y value must be less than {self.resolution_y}" + msg = "The y value must be less than %d" % self.resolution_y logging.exception(msg) raise self.gcode.error(msg) return y * self.fb_stride + x * self.channels - def get_image_buffer(self, path, strip_alpha=True) -> dict: + def get_image_buffer(self, path, strip_alpha=True): """ Reads an image from disk and return it buffer, ready to send to fb. Note that it does not do any file check, @@ -254,7 +254,7 @@ class FramebufferDisplay: self.wait_framebuffer_thread() self._framebuffer_thread = threading.Thread( target=self.clear_buffer, - name=f"Clears the fb{self.framebuffer_index}") + name="Clears the fb%d" % self.framebuffer_index) self._framebuffer_thread.start() if wait_thread: self._framebuffer_thread.join() @@ -273,26 +273,27 @@ class FramebufferDisplay: return if not isinstance(buffer, (list, bytes)): - msg = (f"The buffer must be a 1D byte array. " - f"{type(buffer)} was passed") + msg = ("The buffer must be a 1D byte array. %s was passed" + % type(buffer)) logging.exception(msg) raise self.gcode.error(msg) if offset < 0: - msg = f"The offset {offset} can not be negative value." + msg = "The offset %d can not be negative value." % offset logging.exception(msg) raise self.gcode.error(msg) if clear_flag < 0 or clear_flag > CLEAR_AUTO_FLAG: - msg = f"The clear flag must be between 0 and {CLEAR_AUTO_FLAG}." + msg = "The clear flag must be between 0 and %d." % CLEAR_AUTO_FLAG raise self.gcode.error(msg) clear = clear_flag buffer_size = len(buffer) if buffer_size + offset > self.fb_maxsize: - msg = (f"The buffer size of {buffer_size} + {offset} is greater " - f"than actual framebuffer size of {self.fb_maxsize}.") + msg = ("The buffer size of %d + %d is greater " + "than actual framebuffer size of %d." + % (buffer_size, offset, self.fb_maxsize)) logging.exception(msg) raise self.gcode.error(msg) @@ -304,8 +305,9 @@ class FramebufferDisplay: if buffer_stride > 0 and buffer_stride < self.fb_stride: is_region = True if buffer_size % buffer_stride != 0: - msg = (f"The buffer stride of {buffer_stride} must be an exact" - f" multiple of the buffer size {buffer_size}.") + msg = ("The buffer stride of %d must be an exact" + " multiple of the buffer size %d." + % (buffer_stride, buffer_size)) logging.exception(msg) raise self.gcode.error(msg) @@ -317,19 +319,18 @@ class FramebufferDisplay: logging.exception(msg) raise self.gcode.error(msg) - with self._framebuffer_lock: + with (self._framebuffer_lock): if offset > 0: if clear: self.fb_memory_map.write( - (0).to_bytes(1, byteorder='little') * offset) + struct.pack(' 0: if clear: @@ -341,11 +342,9 @@ class FramebufferDisplay: self.fb_memory_map.write(buffer) pos = self.fb_memory_map.tell() - self.gcode.error(f"pos: {pos}") if clear and pos < self.fb_maxsize: self.fb_memory_map.write( - (0).to_bytes(1, byteorder='little') - * (self.fb_maxsize - pos)) + struct.pack(' 255: @@ -398,8 +397,9 @@ class FramebufferDisplay: buffer_size = len(buffer) if self.fb_maxsize % buffer_size != 0: msg = ( - f"The buffer size of {buffer_size} must be an exact" - f" multiple of the framebuffer size {self.fb_maxsize}.") + "The buffer size of %d must be an exact" + " multiple of the framebuffer size %d." + % (buffer_size, self.fb_maxsize)) logging.exception(msg) raise self.gcode.error(msg) self.write_buffer(buffer * (self.fb_maxsize // buffer_size)) @@ -413,7 +413,7 @@ class FramebufferDisplay: self.wait_framebuffer_thread() self._framebuffer_thread = threading.Thread( target=self.fill_buffer, args=(color,), - name=f"Fill the fb{self.framebuffer_index} with {color}") + name="Fill the fb%d with %d" % (self.framebuffer_index, color)) self._framebuffer_thread.start() if wait_thread: self._framebuffer_thread.join() @@ -427,17 +427,17 @@ class FramebufferDisplay: """ gcode = self.printer.lookup_object('gcode') if offset < 0: - msg = f"The offset {offset} can not be negative value." + msg = "The offset %d can not be negative value." % offset logging.exception(msg) raise gcode.error(msg) if not isinstance(path, str): - msg = f"Path must be a string." + msg = "Path must be a string." logging.exception(msg) raise gcode.error(msg) if not os.path.isfile(path): - msg = f"The file '{path}' does not exists." + msg = "The file '%s' does not exists." % path logging.exception(msg) raise gcode.error(msg) @@ -456,7 +456,7 @@ class FramebufferDisplay: self.wait_framebuffer_thread() self._framebuffer_thread = threading.Thread( target=lambda: self.draw_image(path, offset, clear_flag), - name=f"Render an image to fb{self.framebuffer_index}") + name="Render an image to fb%d" % self.framebuffer_index) self._framebuffer_thread.start() if wait_thread: self._framebuffer_thread.join() @@ -464,7 +464,7 @@ class FramebufferDisplay: class FramebufferDisplayWrapper(FramebufferDisplay): def __init__(self, config): - super().__init__(config) + super(FramebufferDisplayWrapper, self).__init__(config) device_name = config.get_name().split()[1] self.gcode.register_mux_command("FRAMEBUFFER_CLEAR", "DEVICE", @@ -495,7 +495,7 @@ class FramebufferDisplayWrapper(FramebufferDisplay): wait_thread = gcmd.get_int('WAIT', 0, 0, 1) if not os.path.isfile(path): - raise gcmd.error(f"The file '{path}' does not exists.") + raise gcmd.error("The file '%s' does not exists." % path) self.draw_image_threaded(path, offset, clear_flag, wait_thread) diff --git a/klippy/extras/msla_display.py b/klippy/extras/msla_display.py index e419c673e..5182be96a 100644 --- a/klippy/extras/msla_display.py +++ b/klippy/extras/msla_display.py @@ -24,7 +24,7 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): """ def __init__(self, config): - super().__init__(config) + super(mSLADisplay, self).__init__(config) # CACHE CONFIG self.buffer_cache_count = config.getint('cache', 0, 0, 100) @@ -67,7 +67,7 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): self.uvled_output_pin_name, None) if self.uvled is None: - msg = f"The [output_pin %s] or [pwm_tool %s] was not found." % ( + msg = "The [output_pin %s] or [pwm_tool %s] was not found." % ( self.uvled_output_pin_name, self.uvled_output_pin_name) logging.exception(msg) raise config.error(msg) @@ -81,27 +81,27 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): self.clear_cache) # Register commands - gcode = self.printer.lookup_object('gcode') - gcode.register_command("MSLA_DISPLAY_VALIDATE", + self.gcode = self.printer.lookup_object('gcode') + self.gcode.register_command("MSLA_DISPLAY_VALIDATE", self.cmd_MSLA_DISPLAY_VALIDATE, desc=self.cmd_MSLA_DISPLAY_VALIDATE_help) - gcode.register_command("MSLA_DISPLAY_TEST", + self.gcode.register_command("MSLA_DISPLAY_TEST", self.cmd_MSLA_DISPLAY_TEST, desc=self.cmd_MSLA_DISPLAY_TEST_help) - gcode.register_command("MSLA_DISPLAY_RESPONSE_TIME", + self.gcode.register_command("MSLA_DISPLAY_RESPONSE_TIME", self.cmd_MSLA_DISPLAY_RESPONSE_TIME, desc=self.cmd_MSLA_DISPLAY_RESPONSE_TIME_help) - gcode.register_command('M1450', self.cmd_M1450, # Display clear + self.gcode.register_command('M1450', self.cmd_M1450, # Display clear desc=self.cmd_M1451_help) - gcode.register_command('M1451', self.cmd_M1451, # Display image + self.gcode.register_command('M1451', self.cmd_M1451, # Display image desc=self.cmd_M1451_help) - gcode.register_command("MSLA_UVLED_RESPONSE_TIME", + self.gcode.register_command("MSLA_UVLED_RESPONSE_TIME", self.cmd_MSLA_UVLED_RESPONSE_TIME, desc=self.cmd_MSLA_UVLED_RESPONSE_TIME_help) - gcode.register_command('M1400', self.cmd_M1400, # UVLED SET + self.gcode.register_command('M1400', self.cmd_M1400, # UVLED SET desc=self.cmd_M1400_help) - gcode.register_command('M1401', self.cmd_M1401, # UVLED OFF + self.gcode.register_command('M1401', self.cmd_M1401, # UVLED OFF desc=self.cmd_M1401_help) def get_status(self, eventtime): @@ -115,9 +115,9 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): """ Clear all cached buffers """ - self._cache.clear() + self._cache = [] - def _can_cache(self) -> bool: + def _can_cache(self): """ Returns true if is ready to cache @rtype: bool @@ -126,7 +126,7 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): and (self._cache_thread is None or not self._cache_thread.is_alive())) - def _wait_cache_thread(self) -> bool: + def _wait_cache_thread(self): """ Waits for cache thread completion @rtype: bool @@ -160,7 +160,7 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): di = self.get_image_buffer(new_path) self._cache.append(BufferCache(new_path, di)) - def _get_cache_index(self, path) -> int: + def _get_cache_index(self, path): """ Gets the index of the cache position on the list given a path @param path: Path to seek @@ -189,11 +189,11 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): if self.buffer_cache_count > 0: self._wait_cache_thread() # May be worth to wait if streaming - i = self._get_cache_index(path) - if i >= 0: - cache = self._cache[i] + index = self._get_cache_index(path) + if index >= 0: + cache = self._cache[index] # RTrim up to cache - self._cache = self._cache[i + 1:] + self._cache = self._cache[index+1:] if self._can_cache(): self._cache_thread = threading.Thread( @@ -243,64 +243,68 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): resolution_split = resolution.split(',', 1) if len(resolution_split) < 2: - raise gcmd.error(f"The resolution of {resolution} is malformed. " - f"Format: RESOLUTION_X,RESOLUTION_Y.") + raise gcmd.error("The resolution of %d is malformed. " + "Format: RESOLUTION_X,RESOLUTION_Y." % resolution) try: resolution_x = int(resolution_split[0]) except: - raise gcmd.error(f"The resolution x must be an integer number.") + raise gcmd.error("The resolution x must be an integer number.") try: resolution_y = int(resolution_split[1]) except: - raise gcmd.error(f"The resolution y must be an integer.") + raise gcmd.error("The resolution y must be an integer.") if strict: if resolution_x > self.resolution_x: raise gcmd.error( - f"The resolution X of {resolution_x} is invalid. " - f"Should be equal to {self.resolution_x}.") + "The resolution X of %d is invalid. " + "Should be equal to %d." + % (resolution_x, self.resolution_x)) if resolution_y > self.resolution_y: raise gcmd.error( - f"The resolution Y of {resolution_y} is invalid. " - f"Should be equal to {self.resolution_y}.") + "The resolution Y of %d is invalid. " + "Should be equal to %d." + % (resolution_y, self.resolution_y)) else: if resolution_x > self.resolution_x: - raise gcmd.error(f"The resolution X of {resolution_x} is " - f"invalid. Should be less or equal to " - f"{self.resolution_x}.") + raise gcmd.error("The resolution X of %d is " + "invalid. Should be less or equal to %d." + % (resolution_x, self.resolution_x)) if resolution_y > self.resolution_y: - raise gcmd.error(f"The resolution Y of {resolution_y} is " - f"invalid. Should be less or equal to " - f"{self.resolution_y}.") + raise gcmd.error("The resolution Y of %d is " + "invalid. Should be less or equal to %d." + % (resolution_y, self.resolution_y)) pixel_size_split = pixel_size.split(',', 1) if len(pixel_size_split) < 2: - raise gcmd.error(f"The pixel size of {pixel_size} is malformed. " - f"Format: PIXEL_WIDTH,PIXEL_HEIGHT.") + raise gcmd.error("The pixel size of %f is malformed. " + "Format: PIXEL_WIDTH,PIXEL_HEIGHT." % pixel_size) try: pixel_width = float(pixel_size_split[0]) except: - raise gcmd.error(f"The pixel width must be an floating point " - f"number.") + raise gcmd.error("The pixel width must be an floating point " + "number.") try: pixel_height = float(pixel_size_split[1]) except: - raise gcmd.error(f"The pixel height must be an floating point " - f"number.") + raise gcmd.error("The pixel height must be an floating point " + "number.") if pixel_width != self.pixel_width: - raise gcmd.error(f"The pixel width of {pixel_width} is invalid. " - f"Should be {self.pixel_width}.") + raise gcmd.error("The pixel width of %f is invalid. " + "Should be %f." + % (pixel_width, self.pixel_width)) if pixel_height != self.pixel_height: - raise gcmd.error(f"The pixel height of {pixel_height} is invalid. " - f"Should be {self.pixel_height}.") + raise gcmd.error("The pixel height of %f is invalid. " + "Should be %f." + % (pixel_height, self.pixel_height)) cmd_MSLA_DISPLAY_TEST_help = ("Test the display by showing full white image" " and grey shades. Use a white paper on top " @@ -331,13 +335,13 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): self.set_uvled_on() while color > 0: - gcmd.respond_raw(f"Fill color: {color}") + gcmd.respond_raw("Fill color: %d" % color) self.fill_buffer(color) color -= decrement toolhead.dwell(delay) self.set_uvled_off() self.clear_buffer() - gcmd.respond_raw(f"Test finished.") + gcmd.respond_raw("Test finished.") cmd_MSLA_DISPLAY_RESPONSE_TIME_help = ("Sends a buffer to display and test " "it response time to complete the " @@ -436,13 +440,14 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): params = gcmd.get_raw_command_parameters().strip() if 'F' not in params: - raise gcmd.error(f"Error on '{gcmd.get_command()}': missing F") + raise gcmd.error("Error on '%s': missing F" % gcmd.get_command()) toolhead = self.printer.lookup_object('toolhead') match = self._M1451_file_regex.search(params) if not match: - raise gcmd.error(f"Error on '{gcmd.get_command()}': The F parameter" - f" is malformed. Use a proper image file.") + raise gcmd.error("Error on '%s': The F parameter" + " is malformed. Use a proper image file." + % gcmd.get_command()) path = match.group(1).strip(' "\'') path = os.path.normpath(os.path.expanduser(path)) @@ -459,14 +464,14 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): self.write_buffer_threaded(di['buffer'], di['stride'], offset, clear_flag, wait_thread) - def is_exposure(self) -> bool: + def is_exposure(self): """ True if the UV LED is on, exposure a layer, otherwise False @return: """ return self.uvled.last_value > 0 - def is_uvled_pwm(self) -> bool: + def is_uvled_pwm(self): """ True if UV LED is configured as PWM pin, otherwise False """ @@ -577,18 +582,18 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): if etype == 0: offset += 0.2 calculated_offset = max(0, - round((exposure_time - delay + offset) - * 1000)) + round((exposure_time - delay + offset) * 1000)) exposure_factor = round(exposure_time / delay, 2) - gcmd.respond_raw(f"UV LED RESPONSE TIME TEST ({delay}s):\n" - f"Switch time: {round(enable_time * 1000)} ms\n" - f"Exposure time: {exposure_time} s\n" - f"Total time: {time_total} s\n" - f"Exposure time factor: {exposure_factor} x\n" - f"Calculated delay offset: {calculated_offset} ms\n" - ) + gcmd.respond_raw("UV LED RESPONSE TIME TEST (%fs):\n" + "Switch time: %d ms\n" + "Exposure time: %f s\n" + "Total time: %f s\n" + "Exposure time factor: %f x\n" + "Calculated delay offset: %d ms\n" + % (delay, round(enable_time * 1000), exposure_time, + time_total, exposure_factor, calculated_offset)) cmd_M1400_help = "Turn the main UV LED to cure the pixels." @@ -626,21 +631,23 @@ class BufferCache: self.data = data self.pathinfo = Path(path) - def new_path_layerindex(self, n) -> str: + def new_path_layerindex(self, n): """ Return a new path with same base directory but new layer index @param n: @return: """ - return re.sub(rf"\d+\{self.pathinfo.suffix}$", - f"{n}{self.pathinfo.suffix}", self.path) + return re.sub(r"\d+%s$" % re.escape(self.pathinfo.suffix), + "%d%s" % (n, self.pathinfo.suffix), + self.path) - def get_layer_index(self) -> int: + def get_layer_index(self): """ Gets the layer index from a path @return: New layer index. -1 if not founded. """ - match = re.search(rf"(\d+)\{self.pathinfo.suffix}$", self.path) + match = re.search(r"(\d+)%s$" % re.escape(self.pathinfo.suffix), + self.path) if match is None: return -1 diff --git a/klippy/extras/virtual_sdcard.py b/klippy/extras/virtual_sdcard.py index cbc445741..bb77ef5bc 100644 --- a/klippy/extras/virtual_sdcard.py +++ b/klippy/extras/virtual_sdcard.py @@ -236,7 +236,7 @@ class VirtualSD: hashfile = os.path.join(self.sdcard_archive_temp_dirname, DEFAULT_ARCHIVE_HASH_FILENAME) - gcmd.respond_raw(f"Calculating {fname} hash") + gcmd.respond_raw("Calculating %s hash" % fname) hash = self._file_hash(os.path.join(self.sdcard_dirname, fname)) if os.path.isfile(hashfile): @@ -252,13 +252,13 @@ class VirtualSD: if need_extract: if os.path.isdir(self.sdcard_archive_temp_dirname): shutil.rmtree(self.sdcard_archive_temp_dirname) - gcmd.respond_raw(f"Decompressing {fname}...") + gcmd.respond_raw("Decompressing %s..." % fname) timenow = time.time() zip_file.extractall( self.sdcard_archive_temp_dirname) timenow = time.time() - timenow - gcmd.respond_raw(f"Decompress done in {timenow:.2f}" - f" seconds") + gcmd.respond_raw("Decompress done in %.2f seconds" + % timenow) with open(hashfile, 'w') as f: f.write(hash) @@ -275,13 +275,13 @@ class VirtualSD: if need_extract: if os.path.isdir(self.sdcard_archive_temp_dirname): shutil.rmtree(self.sdcard_archive_temp_dirname) - gcmd.respond_raw(f"Decompressing {fname}...") + gcmd.respond_raw("Decompressing %s..." % fname) timenow = time.time() tar_file.extractall( self.sdcard_archive_temp_dirname) timenow = time.time() - timenow - gcmd.respond_raw(f"Decompress done in {timenow:.2f}" - f" seconds") + gcmd.respond_raw("Decompress done in %.2f seconds" + % timenow) with open(hashfile, 'w') as f: f.write(hash) @@ -418,7 +418,7 @@ class VirtualSD: else: self.print_stats.note_complete() return self.reactor.NEVER - def _file_hash(self, filepath, block_size=2**20) -> str: + def _file_hash(self, filepath, block_size=2**20): md5 = hashlib.md5() with open(filepath, "rb") as f: while True: From f2fd8cad4ee0685c6816226cb3c8c4932e38cb90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Concei=C3=A7=C3=A3o?= Date: Sun, 5 May 2024 23:19:23 +0100 Subject: [PATCH 28/32] Fix UTF char to ASCII --- klippy/extras/msla_display.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/klippy/extras/msla_display.py b/klippy/extras/msla_display.py index 5182be96a..02f6777b6 100644 --- a/klippy/extras/msla_display.py +++ b/klippy/extras/msla_display.py @@ -601,8 +601,8 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): """ Turn the main UV LED to cure the pixels. M1400 comes from the wavelength of UV radiation (UVR) - lies in the range of 100–400 nm, and is further subdivided into - UVA (315–400 nm), UVB (280–315 nm), and UVC (100–280 nm). + lies in the range of 100-400 nm, and is further subdivided into + UVA (315-400 nm), UVB (280-315 nm), and UVC (100-280 nm). Syntax: M1400 S[0-255] P[ms] S: LED Power (Non PWM LEDs will turn on from 1 to 255). (float) From 6253386358d0286ab9f2864431a3e2e71a24134d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Concei=C3=A7=C3=A3o?= Date: Sun, 5 May 2024 23:31:31 +0100 Subject: [PATCH 29/32] Remove pathlib dependency --- klippy/extras/msla_display.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/klippy/extras/msla_display.py b/klippy/extras/msla_display.py index 02f6777b6..79094aa72 100644 --- a/klippy/extras/msla_display.py +++ b/klippy/extras/msla_display.py @@ -11,7 +11,6 @@ import threading import re import time import tempfile -from pathlib import Path from PIL import Image from . import framebuffer_display, pwm_tool, pwm_cycle_time, output_pin @@ -629,7 +628,6 @@ class BufferCache: def __init__(self, path, data): self.path = path self.data = data - self.pathinfo = Path(path) def new_path_layerindex(self, n): """ @@ -637,8 +635,9 @@ class BufferCache: @param n: @return: """ - return re.sub(r"\d+%s$" % re.escape(self.pathinfo.suffix), - "%d%s" % (n, self.pathinfo.suffix), + extension = self.path[self.path.rfind('.'):] + return re.sub(r"\d+%s$" % re.escape(extension), + "%d%s" % (n, extension), self.path) def get_layer_index(self): @@ -646,8 +645,8 @@ class BufferCache: Gets the layer index from a path @return: New layer index. -1 if not founded. """ - match = re.search(r"(\d+)%s$" % re.escape(self.pathinfo.suffix), - self.path) + extension = self.path[self.path.rfind('.'):] + match = re.search(r"(\d+)%s$" % re.escape(extension), self.path) if match is None: return -1 From e55603873ce52d9e1215a74858b6769dac3467b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Concei=C3=A7=C3=A3o?= Date: Sun, 12 May 2024 18:13:41 +0100 Subject: [PATCH 30/32] Remove extruder from move if not in settings --- klippy/extras/display/menu.cfg | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/klippy/extras/display/menu.cfg b/klippy/extras/display/menu.cfg index 62d6c7009..a1a501246 100644 --- a/klippy/extras/display/menu.cfg +++ b/klippy/extras/display/menu.cfg @@ -380,7 +380,7 @@ gcode: [menu __main __control __move_10mm __axis_e] type: input -enable: {not printer.idle_timeout.state == "Printing"} +enable: {not printer.idle_timeout.state == "Printing" and ('extruder' in printer)} name: Move E:{'%+06.1f' % menu.input} input: 0 input_min: -{printer.configfile.config.extruder.max_extrude_only_distance|default(50)} @@ -440,7 +440,7 @@ gcode: [menu __main __control __move_1mm __axis_e] type: input -enable: {not printer.idle_timeout.state == "Printing"} +enable: {not printer.idle_timeout.state == "Printing" and ('extruder' in printer)} name: Move E:{'%+06.1f' % menu.input} input: 0 input_min: -{printer.configfile.config.extruder.max_extrude_only_distance|default(50)} @@ -500,7 +500,7 @@ gcode: [menu __main __control __move_01mm __axis_e] type: input -enable: {not printer.idle_timeout.state == "Printing"} +enable: {not printer.idle_timeout.state == "Printing" and ('extruder' in printer)} name: Move E:{'%+06.1f' % menu.input} input: 0 input_min: -{printer.configfile.config.extruder.max_extrude_only_distance|default(50)} From 9742fd4741bfb1b5b0e6d5c3169b0ffd659bac9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Concei=C3=A7=C3=A3o?= Date: Mon, 9 Feb 2026 10:14:29 -0100 Subject: [PATCH 31/32] Add callback --- klippy/extras/msla_display.py | 18 ++++++++++++++++++ klippy/extras/virtual_sdcard.py | 2 ++ 2 files changed, 20 insertions(+) diff --git a/klippy/extras/msla_display.py b/klippy/extras/msla_display.py index 79094aa72..0355a843a 100644 --- a/klippy/extras/msla_display.py +++ b/klippy/extras/msla_display.py @@ -479,6 +479,17 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): or (isinstance(self.uvled, output_pin.PrinterOutputPin) and self.uvled.is_pwm)) + def _set_uvled_callback(self, print_time, value, delay=0): + """ + The uvled setter callback for the lookahead function + """ + self.uvled._set_pin(print_time, value) + if value > 0 and delay > 0: + delay -= self.uvled_response_delay / 1000. + if delay < 0: + delay = 0 + self.uvled._set_pin(print_time + delay, 0) + def set_uvled(self, value, delay=0): """ Turns the UV LED on or off. @@ -502,6 +513,13 @@ class mSLADisplay(framebuffer_display.FramebufferDisplay): return toolhead = self.printer.lookup_object('toolhead') + + #toolhead.register_lookahead_callback( + # lambda print_time: self._set_uvled_callback(print_time, + # value, delay)) + #toolhead.wait_moves() + #return + toolhead.register_lookahead_callback( lambda print_time: self.uvled._set_pin(print_time, value)) diff --git a/klippy/extras/virtual_sdcard.py b/klippy/extras/virtual_sdcard.py index bb77ef5bc..9b8fb475e 100644 --- a/klippy/extras/virtual_sdcard.py +++ b/klippy/extras/virtual_sdcard.py @@ -103,6 +103,7 @@ class VirtualSD: self.sdcard_dirname, followlinks=True): for name in files: ext = name[name.rfind('.')+1:] + if ext not in VALID_GCODE_EXTS + VALID_ARCHIVE_EXTS: continue @@ -117,6 +118,7 @@ class VirtualSD: count = 0 for entry in entries: entry_ext = entry[entry.rfind('.') + 1:] + if entry_ext in VALID_GCODE_EXTS: count += 1 if count != 1: From 6c59e0b4d1cee53f7d569ccfa3473a23e6a5422b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Concei=C3=A7=C3=A3o?= Date: Mon, 9 Feb 2026 12:10:13 -0100 Subject: [PATCH 32/32] Update klippy-requirements.txt --- scripts/klippy-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/klippy-requirements.txt b/scripts/klippy-requirements.txt index a5ae7df33..a3d013b24 100644 --- a/scripts/klippy-requirements.txt +++ b/scripts/klippy-requirements.txt @@ -19,4 +19,4 @@ python-can==3.3.4 setuptools==78.1.1 ; python_version >= '3.12' # Needed by python-can # msgspec is an optional dependency of webhooks.py msgspec==0.19.0 ; python_version >= '3.9' -pillow \ No newline at end of file +pillow