555 lines
24 KiB
Python
555 lines
24 KiB
Python
import configparser
|
|
import gettext
|
|
import os
|
|
import logging
|
|
import json
|
|
import re
|
|
import copy
|
|
import pathlib
|
|
|
|
from io import StringIO
|
|
|
|
from os import path
|
|
|
|
SCREEN_BLANKING_OPTIONS = [
|
|
300, # 5 Minutes
|
|
900, # 15 Minutes
|
|
1800, # 30 Minutes
|
|
3600, # 1 Hour
|
|
7200, # 2 Hours
|
|
14400, # 4 Hours
|
|
]
|
|
|
|
klipperscreendir = pathlib.Path(__file__).parent.resolve().parent
|
|
|
|
|
|
class ConfigError(Exception):
|
|
pass
|
|
|
|
|
|
class KlipperScreenConfig:
|
|
config = None
|
|
configfile_name = "KlipperScreen.conf"
|
|
do_not_edit_line = "#~# --- Do not edit below this line. This section is auto generated --- #~#"
|
|
do_not_edit_prefix = "#~#"
|
|
|
|
def __init__(self, configfile, screen=None):
|
|
self.lang_list = None
|
|
self.errors = []
|
|
self.default_config_path = os.path.join(klipperscreendir, "ks_includes", "defaults.conf")
|
|
self.config = configparser.ConfigParser()
|
|
self.config_path = self.get_config_file_location(configfile)
|
|
logging.debug(f"Config path location: {self.config_path}")
|
|
self.defined_config = None
|
|
self.lang = None
|
|
self.langs = {}
|
|
|
|
try:
|
|
self.config.read(self.default_config_path)
|
|
if self.config_path != self.default_config_path:
|
|
user_def, saved_def = self.separate_saved_config(self.config_path)
|
|
self.defined_config = configparser.ConfigParser()
|
|
self.defined_config.read_string(user_def)
|
|
|
|
includes = [i[8:] for i in self.defined_config.sections() if i.startswith("include ")]
|
|
for include in includes:
|
|
self._include_config("/".join(self.config_path.split("/")[:-1]), include)
|
|
|
|
self.exclude_from_config(self.defined_config)
|
|
|
|
self.log_config(self.defined_config)
|
|
self.config.read_string(user_def)
|
|
if saved_def is not None:
|
|
self.config.read_string(saved_def)
|
|
logging.info(f"====== Saved Def ======\n{saved_def}\n=======================")
|
|
# This is the final config
|
|
# self.log_config(self.config)
|
|
if self.validate_config():
|
|
logging.info('Configuration validated succesfuly')
|
|
else:
|
|
logging.error('Invalid configuration detected !!!')
|
|
logging.info('Loading default config')
|
|
self.config = configparser.ConfigParser()
|
|
self.config.read(self.default_config_path)
|
|
except KeyError as Kerror:
|
|
msg = f"Error reading config: {self.config_path}\n{Kerror}"
|
|
logging.exception(msg)
|
|
self.errors.append(msg)
|
|
raise ConfigError(msg) from Kerror
|
|
except ValueError as Verror:
|
|
msg = f"Invalid Value in the config:\n{Verror}"
|
|
logging.exception(msg)
|
|
self.errors.append(msg)
|
|
except Exception as e:
|
|
msg = f"Unknown error with the config:\n{e}"
|
|
logging.exception(msg)
|
|
self.errors.append(msg)
|
|
|
|
printers = sorted([i for i in self.config.sections() if i.startswith("printer ")])
|
|
if len(printers) == 0:
|
|
printers.append("Printer Printer")
|
|
self.printers = [
|
|
{printer[8:]: {
|
|
"moonraker_host": self.config.get(printer, "moonraker_host", fallback="127.0.0.1"),
|
|
"moonraker_port": self.config.get(printer, "moonraker_port", fallback="7125"),
|
|
"moonraker_api_key": self.config.get(printer, "moonraker_api_key", fallback="").replace('"', '')
|
|
}} for printer in printers
|
|
]
|
|
|
|
conf_printers_debug = copy.deepcopy(self.printers)
|
|
for printer in conf_printers_debug:
|
|
name = list(printer)[0]
|
|
item = conf_printers_debug[conf_printers_debug.index(printer)]
|
|
if item[name]['moonraker_api_key'] != "":
|
|
item[name]['moonraker_api_key'] = "redacted"
|
|
logging.debug(f"Configured printers: {json.dumps(conf_printers_debug, indent=2)}")
|
|
|
|
self.create_translations()
|
|
self._create_configurable_options(screen)
|
|
|
|
def create_translations(self):
|
|
lang_path = os.path.join(klipperscreendir, "ks_includes", "locales")
|
|
self.lang_list = [d for d in os.listdir(lang_path) if not os.path.isfile(os.path.join(lang_path, d))]
|
|
self.lang_list.sort()
|
|
for lng in self.lang_list:
|
|
self.langs[lng] = gettext.translation('KlipperScreen', localedir=lang_path, languages=[lng], fallback=True)
|
|
|
|
lang = self.get_main_config().get("language", None)
|
|
logging.debug(f"Selected lang: {lang} OS lang: {os.getenv('LANG')}")
|
|
self.install_language(lang)
|
|
|
|
def install_language(self, lang):
|
|
if lang is None or lang == "system_lang":
|
|
for language in self.lang_list:
|
|
if os.getenv('LANG').lower().startswith(language):
|
|
logging.debug("Using system lang")
|
|
lang = language
|
|
if lang not in self.lang_list:
|
|
logging.error(f"lang: {lang} not found")
|
|
logging.info(f"Available lang list {self.lang_list}")
|
|
lang = "en"
|
|
logging.info(f"Using lang {lang}")
|
|
self.lang = self.langs[lang]
|
|
self.lang.install(names=['gettext', 'ngettext'])
|
|
|
|
def validate_config(self):
|
|
valid = True
|
|
for section in self.config:
|
|
if section == 'DEFAULT' or section.startswith('include '):
|
|
# Do not validate 'DEFAULT' or 'include*' sections
|
|
continue
|
|
bools = strs = numbers = ()
|
|
if section == 'main':
|
|
bools = (
|
|
'invert_x', 'invert_y', 'invert_z', '24htime', 'only_heaters', 'show_cursor', 'confirm_estop',
|
|
'autoclose_popups', 'use_dpms', 'use_default_menu', 'side_macro_shortcut', 'use-matchbox-keyboard',
|
|
'show_heater_power'
|
|
)
|
|
strs = (
|
|
'default_printer', 'language', 'print_sort_dir', 'theme', 'screen_blanking', 'font_size',
|
|
'print_estimate_method', 'screen_blanking'
|
|
)
|
|
numbers = (
|
|
'job_complete_timeout', 'job_error_timeout', 'move_speed_xy', 'move_speed_z',
|
|
'print_estimate_compensation', 'width', 'height',
|
|
)
|
|
elif section.startswith('printer '):
|
|
bools = (
|
|
'invert_x', 'invert_y', 'invert_z',
|
|
)
|
|
strs = (
|
|
'moonraker_api_key', 'moonraker_host', 'titlebar_name_type',
|
|
'screw_positions', 'power_devices', 'titlebar_items', 'z_babystep_values',
|
|
'extrude_distances', "extrude_speeds",
|
|
)
|
|
numbers = (
|
|
'moonraker_port', 'move_speed_xy', 'move_speed_z',
|
|
'calibrate_x_position', 'calibrate_y_position',
|
|
)
|
|
elif section.startswith('preheat '):
|
|
strs = ('gcode', '')
|
|
numbers = [f'{option}' for option in self.config[section] if option != 'gcode']
|
|
elif section.startswith('menu '):
|
|
strs = ('name', 'icon', 'panel', 'method', 'params', 'enable', 'confirm')
|
|
elif section == 'bed_screws':
|
|
# This section may be deprecated in favor of moving this options under the printer section
|
|
numbers = ('rotation', '')
|
|
strs = ('screw_positions', '')
|
|
elif section.startswith('graph') or section.startswith('displayed_macros'):
|
|
bools = [f'{option}' for option in self.config[section]]
|
|
elif section.startswith('z_calibrate_position'):
|
|
# This section may be deprecated in favor of moving this options under the printer section
|
|
numbers = ('calibrate_x_position', 'calibrate_y_position')
|
|
else:
|
|
self.errors.append(f'Section [{section}] not recognized')
|
|
|
|
for key in self.config[section]:
|
|
if key not in bools and key not in strs and key not in numbers:
|
|
msg = f'Option "{key}" not recognized for section "[{section}]"'
|
|
self.errors.append(msg)
|
|
# This most probably is not a big issue, continue to load the config
|
|
elif key in numbers and not self.is_float(self.config[section][key]) \
|
|
or key in bools and self.config[section][key] not in ["False", "false", "True", "true"]:
|
|
msg = (
|
|
f'Unable to parse "{key}" from [{section}]\n'
|
|
f'Expected a {"number" if key in numbers else "boolean"} but got: {self.config[section][key]}'
|
|
)
|
|
self.errors.append(msg)
|
|
valid = False
|
|
return valid
|
|
|
|
@staticmethod
|
|
def is_float(element):
|
|
try:
|
|
float(element)
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
|
|
def get_errors(self):
|
|
return "".join(f'{error}\n\n' for error in self.errors)
|
|
|
|
def _create_configurable_options(self, screen):
|
|
|
|
self.configurable_options = [
|
|
{"language": {
|
|
"section": "main", "name": _("Language"), "type": "dropdown", "value": "system_lang",
|
|
"callback": screen.change_language, "options": [
|
|
{"name": _("System") + " " + _("(default)"), "value": "system_lang"}]}},
|
|
{"theme": {
|
|
"section": "main", "name": _("Icon Theme"), "type": "dropdown",
|
|
"value": "z-bolt", "callback": screen.restart_warning, "options": [
|
|
{"name": "Z-bolt" + " " + _("(default)"), "value": "z-bolt"}]}},
|
|
{"print_estimate_method": {
|
|
"section": "main", "name": _("Estimated Time Method"), "type": "dropdown",
|
|
"value": "auto", "options": [
|
|
{"name": _("Auto") + " " + _("(default)"), "value": "auto"},
|
|
{"name": _("File"), "value": "file"},
|
|
{"name": _("Filament Used"), "value": "filament"},
|
|
{"name": _("Slicer"), "value": "slicer"}]}},
|
|
{"screen_blanking": {
|
|
"section": "main", "name": _("Screen Power Off Time"), "type": "dropdown",
|
|
"value": "3600", "callback": screen.set_screenblanking_timeout, "options": [
|
|
{"name": _("Never"), "value": "off"}]
|
|
}},
|
|
{"24htime": {"section": "main", "name": _("24 Hour Time"), "type": "binary", "value": "True"}},
|
|
{"side_macro_shortcut": {
|
|
"section": "main", "name": _("Macro shortcut on sidebar"), "type": "binary",
|
|
"value": "True", "callback": screen.toggle_macro_shortcut}},
|
|
{"font_size": {
|
|
"section": "main", "name": _("Font Size"), "type": "dropdown",
|
|
"value": "medium", "callback": screen.restart_warning, "options": [
|
|
{"name": _("Small"), "value": "small"},
|
|
{"name": _("Medium") + " " + _("(default)"), "value": "medium"},
|
|
{"name": _("Large"), "value": "large"}]}},
|
|
{"confirm_estop": {"section": "main", "name": _("Confirm Emergency Stop"), "type": "binary",
|
|
"value": "False"}},
|
|
{"only_heaters": {"section": "main", "name": _("Hide sensors in Temp."), "type": "binary",
|
|
"value": "False", "callback": screen.reload_panels}},
|
|
{"use_dpms": {"section": "main", "name": _("Screen DPMS"), "type": "binary",
|
|
"value": "True", "callback": screen.set_dpms}},
|
|
{"print_estimate_compensation": {
|
|
"section": "main", "name": _("Slicer Time correction (%)"), "type": "scale", "value": "100",
|
|
"range": [50, 150], "step": 1}},
|
|
{"autoclose_popups": {"section": "main", "name": _("Auto-close notifications"), "type": "binary",
|
|
"value": "True"}},
|
|
{"show_heater_power": {"section": "main", "name": _("Show Heater Power"), "type": "binary",
|
|
"value": "False", "callback": screen.reload_panels}},
|
|
# {"": {"section": "main", "name": _(""), "type": ""}}
|
|
]
|
|
|
|
# Options that are in panels and shouldn't be added to the main settings
|
|
panel_options = [
|
|
{"invert_x": {"section": "main", "name": _("Invert X"), "type": None, "value": "False"}},
|
|
{"invert_y": {"section": "main", "name": _("Invert Y"), "type": None, "value": "False"}},
|
|
{"invert_z": {"section": "main", "name": _("Invert Z"), "type": None, "value": "False"}},
|
|
{"move_speed_xy": {"section": "main", "name": _("XY Move Speed (mm/s)"), "type": None, "value": "50"}},
|
|
{"move_speed_z": {"section": "main", "name": _("Z Move Speed (mm/s)"), "type": None, "value": "10"}},
|
|
{"print_sort_dir": {"section": "main", "type": None, "value": "name_asc"}},
|
|
]
|
|
|
|
self.configurable_options.extend(panel_options)
|
|
|
|
lang_opt = self.configurable_options[0]['language']['options']
|
|
for lang in self.lang_list:
|
|
lang_opt.append({"name": lang, "value": lang})
|
|
|
|
t_path = os.path.join(klipperscreendir, 'styles')
|
|
themes = [d for d in os.listdir(t_path) if (not os.path.isfile(os.path.join(t_path, d)) and d != "z-bolt")]
|
|
themes.sort()
|
|
theme_opt = self.configurable_options[1]['theme']['options']
|
|
|
|
for theme in themes:
|
|
theme_opt.append({"name": theme, "value": theme})
|
|
|
|
index = self.configurable_options.index(
|
|
[i for i in self.configurable_options if list(i)[0] == "screen_blanking"][0])
|
|
for num in SCREEN_BLANKING_OPTIONS:
|
|
hour = num // 3600
|
|
if hour > 0:
|
|
name = f'{hour} ' + ngettext("hour", "hours", hour)
|
|
else:
|
|
name = f'{num / 60:.0f} ' + _("minutes")
|
|
self.configurable_options[index]['screen_blanking']['options'].append({
|
|
"name": name,
|
|
"value": f"{num}"
|
|
})
|
|
|
|
for item in self.configurable_options:
|
|
name = list(item)[0]
|
|
vals = item[name]
|
|
if vals['section'] not in self.config.sections():
|
|
self.config.add_section(vals['section'])
|
|
if name not in list(self.config[vals['section']]):
|
|
self.config.set(vals['section'], name, vals['value'])
|
|
|
|
def exclude_from_config(self, config):
|
|
exclude_list = ['preheat']
|
|
if not self.defined_config.getboolean('main', "use_default_menu", fallback=True):
|
|
logging.info("Using custom menu, removing default menu entries.")
|
|
exclude_list.extend(('menu __main', 'menu __print', 'menu __splashscreen'))
|
|
for i in exclude_list:
|
|
for j in config.sections():
|
|
if j.startswith(i):
|
|
for k in list(self.config.sections()):
|
|
if k.startswith(i):
|
|
del self.config[k]
|
|
|
|
def _include_config(self, directory, filepath):
|
|
full_path = filepath if filepath[0] == "/" else f"{directory}/{filepath}"
|
|
parse_files = []
|
|
|
|
if "*" in full_path:
|
|
parent_dir = "/".join(full_path.split("/")[:-1])
|
|
file = full_path.split("/")[-1]
|
|
if not os.path.exists(parent_dir):
|
|
logging.info(f"Config Error: Directory {parent_dir} does not exist")
|
|
return
|
|
files = os.listdir(parent_dir)
|
|
regex = f"^{file.replace('*', '.*')}$"
|
|
parse_files.extend(os.path.join(parent_dir, file) for file in files if re.match(regex, file))
|
|
|
|
else:
|
|
if not os.path.exists(os.path.join(full_path)):
|
|
logging.info(f"Config Error: {full_path} does not exist")
|
|
return
|
|
parse_files.append(full_path)
|
|
|
|
logging.info(f"Parsing files: {parse_files}")
|
|
for file in parse_files:
|
|
config = configparser.ConfigParser()
|
|
config.read(file)
|
|
includes = [i[8:] for i in config.sections() if i.startswith("include ")]
|
|
for include in includes:
|
|
self._include_config("/".join(full_path.split("/")[:-1]), include)
|
|
self.exclude_from_config(config)
|
|
self.log_config(config)
|
|
self.config.read(file)
|
|
|
|
def separate_saved_config(self, config_path):
|
|
user_def = []
|
|
saved_def = []
|
|
found_saved = False
|
|
if not path.exists(config_path):
|
|
return [None, None]
|
|
with open(config_path) as file:
|
|
for line in file:
|
|
line = line.replace('\n', '')
|
|
if line == self.do_not_edit_line:
|
|
found_saved = True
|
|
saved_def = []
|
|
continue
|
|
if found_saved is False:
|
|
user_def.append(line.replace('\n', ''))
|
|
elif line.startswith(self.do_not_edit_prefix):
|
|
saved_def.append(line[(len(self.do_not_edit_prefix) + 1):])
|
|
return ["\n".join(user_def), None if saved_def is None else "\n".join(saved_def)]
|
|
|
|
def get_config_file_location(self, file):
|
|
# Passed config (-c) by default is ~/KlipperScreen.conf
|
|
if path.exists(file):
|
|
return file
|
|
|
|
file = os.path.join(klipperscreendir, self.configfile_name)
|
|
if path.exists(file):
|
|
return file
|
|
file = os.path.join(klipperscreendir, self.configfile_name.lower())
|
|
if path.exists(file):
|
|
return file
|
|
|
|
klipper_config = os.path.join(os.path.expanduser("~/"), "printer_data", "config")
|
|
file = os.path.join(klipper_config, self.configfile_name)
|
|
if path.exists(file):
|
|
return file
|
|
file = os.path.join(klipper_config, self.configfile_name.lower())
|
|
if path.exists(file):
|
|
return file
|
|
|
|
# OLD config folder
|
|
klipper_config = os.path.join(os.path.expanduser("~/"), "klipper_config")
|
|
file = os.path.join(klipper_config, self.configfile_name)
|
|
if path.exists(file):
|
|
return file
|
|
file = os.path.join(klipper_config, self.configfile_name.lower())
|
|
if path.exists(file):
|
|
return file
|
|
|
|
# fallback
|
|
return self.default_config_path
|
|
|
|
def get_config(self):
|
|
return self.config
|
|
|
|
def get_configurable_options(self):
|
|
return self.configurable_options
|
|
|
|
def get_lang(self):
|
|
return self.lang
|
|
|
|
def get_main_config(self):
|
|
return self.config['main']
|
|
|
|
def get_menu_items(self, menu="__main", subsection=""):
|
|
if subsection != "":
|
|
subsection = f"{subsection} "
|
|
index = f"menu {menu} {subsection}"
|
|
items = [i[len(index):] for i in self.config.sections() if i.startswith(index)]
|
|
menu_items = []
|
|
for item in items:
|
|
split = item.split()
|
|
if len(split) == 1:
|
|
menu_items.append(self._build_menu_item(menu, index + item))
|
|
|
|
return menu_items
|
|
|
|
def get_menu_name(self, menu="__main", subsection=""):
|
|
name = f"menu {menu} {subsection}" if subsection != "" else f"menu {menu}"
|
|
return False if name not in self.config else self.config[name].get('name')
|
|
|
|
def get_preheat_options(self):
|
|
index = "preheat "
|
|
items = [i[len(index):] for i in self.config.sections() if i.startswith(index)]
|
|
return {item: self._build_preheat_item(index + item) for item in items}
|
|
|
|
def _build_preheat_item(self, name):
|
|
if name not in self.config:
|
|
return False
|
|
cfg = self.config[name]
|
|
return {opt: cfg.get("gcode", None) if opt == "gcode" else cfg.getfloat(opt, None) for opt in cfg}
|
|
|
|
def get_printer_config(self, name):
|
|
if not name.startswith("printer "):
|
|
name = f"printer {name}"
|
|
|
|
return None if name not in self.config else self.config[name]
|
|
|
|
def get_printer_power_name(self):
|
|
return self.config['settings'].get("printer_power_name", "printer")
|
|
|
|
def get_printers(self):
|
|
return self.printers
|
|
|
|
def save_user_config_options(self):
|
|
save_config = configparser.ConfigParser()
|
|
for item in self.configurable_options:
|
|
name = list(item)[0]
|
|
opt = item[name]
|
|
curval = self.config[opt['section']].get(name)
|
|
if curval != opt["value"] or (
|
|
self.defined_config is not None and opt['section'] in self.defined_config.sections() and
|
|
self.defined_config[opt['section']].get(name, None) not in (None, curval)):
|
|
if opt['section'] not in save_config.sections():
|
|
save_config.add_section(opt['section'])
|
|
save_config.set(opt['section'], name, str(curval))
|
|
|
|
extra_sections = [i for i in self.config.sections() if i.startswith("displayed_macros")]
|
|
extra_sections.extend([i for i in self.config.sections() if i.startswith("graph")])
|
|
for section in extra_sections:
|
|
for item in self.config.options(section):
|
|
value = self.config[section].getboolean(item, fallback=True)
|
|
if value is False or (self.defined_config is not None and
|
|
section in self.defined_config.sections() and
|
|
self.defined_config[section].getboolean(item, fallback=True) is False and
|
|
self.defined_config[section].getboolean(item, fallback=True) != value):
|
|
if section not in save_config.sections():
|
|
save_config.add_section(section)
|
|
save_config.set(section, item, str(value))
|
|
|
|
save_output = self._build_config_string(save_config).split("\n")
|
|
for i in range(len(save_output)):
|
|
save_output[i] = f"{self.do_not_edit_prefix} {save_output[i]}"
|
|
|
|
if self.config_path == self.default_config_path:
|
|
user_def = ""
|
|
saved_def = None
|
|
else:
|
|
user_def, saved_def = self.separate_saved_config(self.config_path)
|
|
|
|
extra_lb = "\n" if saved_def is not None else ""
|
|
contents = "%s\n%s%s\n%s\n%s\n%s\n" % (
|
|
user_def, self.do_not_edit_line, extra_lb, self.do_not_edit_prefix, "\n".join(save_output),
|
|
self.do_not_edit_prefix)
|
|
|
|
if self.config_path != self.default_config_path:
|
|
filepath = self.config_path
|
|
else:
|
|
filepath = os.path.expanduser("~/")
|
|
klipper_config = os.path.join(filepath, "klipper_config")
|
|
if os.path.exists(klipper_config):
|
|
filepath = os.path.join(klipper_config, "KlipperScreen.conf")
|
|
klipper_config = os.path.join(filepath, "printer_data", "config")
|
|
if os.path.exists(klipper_config):
|
|
filepath = os.path.join(klipper_config, "KlipperScreen.conf")
|
|
else:
|
|
filepath = os.path.join(filepath, "KlipperScreen.conf")
|
|
|
|
try:
|
|
with open(filepath, 'w') as file:
|
|
file.write(contents)
|
|
except Exception as e:
|
|
logging.error(f"Error writing configuration file in {filepath}:\n{e}")
|
|
|
|
def set(self, section, name, value):
|
|
self.config.set(section, name, value)
|
|
|
|
def log_config(self, config):
|
|
lines = [
|
|
" "
|
|
"===== Config File =====",
|
|
re.sub(
|
|
r'(moonraker_api_key\s*=\s*\S+)',
|
|
'moonraker_api_key = [redacted]',
|
|
self._build_config_string(config)
|
|
),
|
|
"======================="
|
|
]
|
|
logging.info("\n".join(lines))
|
|
|
|
@staticmethod
|
|
def _build_config_string(config):
|
|
sfile = StringIO()
|
|
config.write(sfile)
|
|
sfile.seek(0)
|
|
return sfile.read().strip()
|
|
|
|
def _build_menu_item(self, menu, name):
|
|
if name not in self.config:
|
|
return False
|
|
cfg = self.config[name]
|
|
item = {
|
|
"name": cfg.get("name"),
|
|
"icon": cfg.get("icon"),
|
|
"panel": cfg.get("panel", False),
|
|
"method": cfg.get("method", False),
|
|
"confirm": cfg.get("confirm", False),
|
|
"enable": cfg.get("enable", True)
|
|
}
|
|
|
|
try:
|
|
item["params"] = json.loads(cfg.get("params", "{}"))
|
|
except Exception as e:
|
|
logging.exception(f"Unable to parse parameters for [{name}]:\n{e}")
|
|
item["params"] = {}
|
|
|
|
return {name[(len(menu) + 6):]: item}
|