From ff2571180c9beaeb4b526429cc5652a50fc02c1e Mon Sep 17 00:00:00 2001 From: Eric Callahan Date: Fri, 17 Jan 2025 10:59:39 -0500 Subject: [PATCH 01/12] update_manager: support internal updaters Signed-off-by: Eric Callahan --- .../components/update_manager/app_deploy.py | 7 ++--- .../components/update_manager/base_deploy.py | 22 ++++++++------ .../components/update_manager/git_deploy.py | 6 ++-- .../update_manager/python_deploy.py | 5 ++-- .../update_manager/system_deploy.py | 9 ++---- .../update_manager/update_manager.py | 29 +++++++++++++++---- .../components/update_manager/zip_deploy.py | 9 ++---- 7 files changed, 49 insertions(+), 38 deletions(-) diff --git a/moonraker/components/update_manager/app_deploy.py b/moonraker/components/update_manager/app_deploy.py index a637b9b31..81fb59edd 100644 --- a/moonraker/components/update_manager/app_deploy.py +++ b/moonraker/components/update_manager/app_deploy.py @@ -31,7 +31,6 @@ if TYPE_CHECKING: from ...confighelper import ConfigHelper from ..klippy_connection import KlippyConnection as Klippy - from .update_manager import CommandHelper from ..machine import Machine from ..file_manager.file_manager import FileManager @@ -39,10 +38,8 @@ DISTRO_ALIASES.extend(distro.like().split()) class AppDeploy(BaseDeploy): - def __init__( - self, config: ConfigHelper, cmd_helper: CommandHelper, prefix: str - ) -> None: - super().__init__(config, cmd_helper, prefix=prefix) + def __init__(self, config: ConfigHelper, prefix: str) -> None: + super().__init__(config, prefix=prefix) self.config = config type_choices = {str(t): t for t in AppType.valid_types()} self.type = config.getchoice("type", type_choices) diff --git a/moonraker/components/update_manager/base_deploy.py b/moonraker/components/update_manager/base_deploy.py index a424d586d..52738a72a 100644 --- a/moonraker/components/update_manager/base_deploy.py +++ b/moonraker/components/update_manager/base_deploy.py @@ -16,13 +16,14 @@ from .update_manager import CommandHelper class BaseDeploy: - def __init__(self, - config: ConfigHelper, - cmd_helper: CommandHelper, - name: Optional[str] = None, - prefix: str = "", - cfg_hash: Optional[str] = None - ) -> None: + cmd_helper: CommandHelper + def __init__( + self, + config: ConfigHelper, + name: Optional[str] = None, + prefix: str = "", + cfg_hash: Optional[str] = None + ) -> None: if name is None: name = self.parse_name(config) self.name = name @@ -30,8 +31,7 @@ def __init__(self, prefix = f"{prefix} {self.name}: " self.prefix = prefix self.server = config.get_server() - self.cmd_helper = cmd_helper - self.refresh_interval = cmd_helper.get_refresh_interval() + self.refresh_interval = self.cmd_helper.get_refresh_interval() refresh_interval = config.getint('refresh_interval', None) if refresh_interval is not None: self.refresh_interval = refresh_interval * 60 * 60 @@ -47,6 +47,10 @@ def parse_name(config: ConfigHelper) -> str: name = name[7:] return name + @staticmethod + def set_command_helper(cmd_helper: CommandHelper) -> None: + BaseDeploy.cmd_helper = cmd_helper + async def initialize(self) -> Dict[str, Any]: umdb = self.cmd_helper.get_umdb() storage: Dict[str, Any] = await umdb.get(self.name, {}) diff --git a/moonraker/components/update_manager/git_deploy.py b/moonraker/components/update_manager/git_deploy.py index 5064abcb4..1ef71f72f 100644 --- a/moonraker/components/update_manager/git_deploy.py +++ b/moonraker/components/update_manager/git_deploy.py @@ -30,8 +30,8 @@ from ..http_client import HttpClient class GitDeploy(AppDeploy): - def __init__(self, config: ConfigHelper, cmd_helper: CommandHelper) -> None: - super().__init__(config, cmd_helper, "Git Repo") + def __init__(self, config: ConfigHelper) -> None: + super().__init__(config, "Git Repo") self._configure_path(config) self._configure_virtualenv(config) self._configure_dependencies(config) @@ -49,7 +49,7 @@ def __init__(self, config: ConfigHelper, cmd_helper: CommandHelper) -> None: "a minimum of 8 characters." ) self.repo = GitRepo( - cmd_helper, self.path, self.name, self.origin, self.moved_origin, + self.cmd_helper, self.path, self.name, self.origin, self.moved_origin, self.primary_branch, self.channel, pinned_commit ) diff --git a/moonraker/components/update_manager/python_deploy.py b/moonraker/components/update_manager/python_deploy.py index a18cc3b1b..3138e55c6 100644 --- a/moonraker/components/update_manager/python_deploy.py +++ b/moonraker/components/update_manager/python_deploy.py @@ -28,7 +28,6 @@ from ...confighelper import ConfigHelper from ...utils.source_info import PackageInfo from ...components.file_manager.file_manager import FileManager - from .update_manager import CommandHelper class PackageSource(Enum): PIP = 0 @@ -37,8 +36,8 @@ class PackageSource(Enum): class PythonDeploy(AppDeploy): - def __init__(self, config: ConfigHelper, cmd_helper: CommandHelper) -> None: - super().__init__(config, cmd_helper, "Python Package") + def __init__(self, config: ConfigHelper) -> None: + super().__init__(config, "Python Package") self._configure_virtualenv(config) if self.virtualenv is None: raise config.error( diff --git a/moonraker/components/update_manager/system_deploy.py b/moonraker/components/update_manager/system_deploy.py index 4be741677..223a6f6c4 100644 --- a/moonraker/components/update_manager/system_deploy.py +++ b/moonraker/components/update_manager/system_deploy.py @@ -35,12 +35,9 @@ class PackageDeploy(BaseDeploy): - def __init__(self, - config: ConfigHelper, - cmd_helper: CommandHelper - ) -> None: - super().__init__(config, cmd_helper, "system", "", "") - cmd_helper.set_package_updater(self) + def __init__(self, config: ConfigHelper) -> None: + super().__init__(config, "system", "", "") + self.cmd_helper.set_package_updater(self) self.use_packagekit = config.getboolean("enable_packagekit", True) self.available_packages: List[str] = [] diff --git a/moonraker/components/update_manager/update_manager.py b/moonraker/components/update_manager/update_manager.py index 4c4412a7e..6d1952ec5 100644 --- a/moonraker/components/update_manager/update_manager.py +++ b/moonraker/components/update_manager/update_manager.py @@ -87,20 +87,21 @@ def __init__(self, config: ConfigHelper) -> None: " in 'refresh_window' cannot be the same.") self.cmd_helper = CommandHelper(config, self.get_updaters) + BaseDeploy.set_command_helper(self.cmd_helper) self.updaters: Dict[str, BaseDeploy] = {} if config.getboolean('enable_system_updates', True): - self.updaters['system'] = PackageDeploy(config, self.cmd_helper) + self.updaters['system'] = PackageDeploy(config) mcfg = self.app_config["moonraker"] kcfg = self.app_config["klipper"] mclass = get_deploy_class(mcfg.get("type"), BaseDeploy) - self.updaters['moonraker'] = mclass(mcfg, self.cmd_helper) + self.updaters['moonraker'] = mclass(mcfg) kclass = BaseDeploy if ( os.path.exists(kcfg.get("path")) and os.path.exists(kcfg.get("env")) ): kclass = get_deploy_class(kcfg.get("type"), BaseDeploy) - self.updaters['klipper'] = kclass(kcfg, self.cmd_helper) + self.updaters['klipper'] = kclass(kcfg) # TODO: The below check may be removed when invalid config options # raise a config error. @@ -129,7 +130,7 @@ def __init__(self, config: ConfigHelper) -> None: self.server.add_warning( f"Invalid type '{client_type}' for section [{section}]") else: - self.updaters[name] = deployer(cfg, self.cmd_helper) + self.updaters[name] = deployer(cfg) except Exception as e: self.server.add_warning( f"[update_manager]: Failed to load extension {name}: {e}", @@ -204,6 +205,24 @@ async def component_init(self) -> None: self._handle_auto_refresh, self.event_loop.get_loop_time() ) + def register_updater(self, name: str, config: Dict[str, str]) -> None: + if name in self.updaters: + raise self.server.error(f"Updater {name} already registered") + cfg = self.app_config.read_supplemental_dict({name: config}) + updater_type = cfg.get("type") + updater_cls = get_deploy_class(updater_type, None) + if updater_cls is None: + raise self.server.error(f"Invalid type '{updater_type}'") + self.updaters[name] = updater_cls(cfg) + + async def refresh_updater(self, updater_name: str, force: bool = False) -> None: + if updater_name not in self.updaters: + return + async with self.cmd_request_lock: + updater = self.updaters[updater_name] + if updater.needs_refresh() or force: + await updater.refresh() + def _set_klipper_repo(self) -> None: if self.klippy_identified_evt is not None: self.klippy_identified_evt.set() @@ -224,7 +243,7 @@ def _set_klipper_repo(self) -> None: kcfg.set_option("type", str(app_type)) notify = not isinstance(kupdater, AppDeploy) kclass = get_deploy_class(app_type, BaseDeploy) - coro = self._update_klipper_repo(kclass(kcfg, self.cmd_helper), notify) + coro = self._update_klipper_repo(kclass(kcfg), notify) self.event_loop.create_task(coro) async def _update_klipper_repo(self, updater: BaseDeploy, notify: bool) -> None: diff --git a/moonraker/components/update_manager/zip_deploy.py b/moonraker/components/update_manager/zip_deploy.py index 87c8ccd2d..aae6d12f8 100644 --- a/moonraker/components/update_manager/zip_deploy.py +++ b/moonraker/components/update_manager/zip_deploy.py @@ -27,16 +27,11 @@ ) if TYPE_CHECKING: from ...confighelper import ConfigHelper - from .update_manager import CommandHelper from ..file_manager.file_manager import FileManager class ZipDeploy(AppDeploy): - def __init__( - self, - config: ConfigHelper, - cmd_helper: CommandHelper - ) -> None: - super().__init__(config, cmd_helper, "Zip Application") + def __init__(self, config: ConfigHelper) -> None: + super().__init__(config, "Zip Application") self._configure_path(config, False) if self.type == AppType.ZIP: self._configure_virtualenv(config) From 152837959a4fcdfb5c251718c6f4fa960f7b4332 Mon Sep 17 00:00:00 2001 From: Eric Callahan Date: Fri, 17 Jan 2025 11:00:31 -0500 Subject: [PATCH 02/12] authorization: add method to return api key Signed-off-by: Eric Callahan --- moonraker/components/authorization.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/moonraker/components/authorization.py b/moonraker/components/authorization.py index bb3e3efe5..7092796ea 100644 --- a/moonraker/components/authorization.py +++ b/moonraker/components/authorization.py @@ -928,6 +928,11 @@ async def check_cors(self, origin: Optional[str]) -> bool: def cors_enabled(self) -> bool: return self.cors_domains is not None + def get_api_key(self) -> Optional[str]: + if not self.enable_api_key: + return None + return self.api_key + def close(self) -> None: self.prune_timer.stop() From 636626506a31a743371e2d549a117c74954466ec Mon Sep 17 00:00:00 2001 From: Eric Callahan Date: Fri, 17 Jan 2025 11:01:06 -0500 Subject: [PATCH 03/12] file_manager: provide method to look up paths by root Signed-off-by: Eric Callahan --- moonraker/components/file_manager/file_manager.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/moonraker/components/file_manager/file_manager.py b/moonraker/components/file_manager/file_manager.py index 392da6048..a969f1e0d 100644 --- a/moonraker/components/file_manager/file_manager.py +++ b/moonraker/components/file_manager/file_manager.py @@ -372,6 +372,12 @@ def get_relative_path(self, root: str, full_path: str) -> str: return "" return os.path.relpath(full_path, start=root_dir) + def get_full_path(self, root: str, relative_path: str) -> pathlib.Path: + root_dir = self.file_paths.get(root, None) + if root_dir is None: + raise self.server.error(f"Unknown root {root}") + return pathlib.Path(root_dir).joinpath(relative_path) + def get_metadata_storage(self) -> MetadataStorage: return self.gcode_metadata From 530f1c201695d172060b14dd9baa6918a08ea5ff Mon Sep 17 00:00:00 2001 From: Eric Callahan Date: Sun, 19 Jan 2025 07:53:14 -0500 Subject: [PATCH 04/12] update_manager: add support for executable types This change refactors zip_deploy.py, renaming it net_deploy.py. Net hosted executables that currently exist on the system with a valid `release_info.json` file can now be updated. Signed-off-by: Eric Callahan --- .../components/update_manager/app_deploy.py | 14 ++- moonraker/components/update_manager/common.py | 3 +- .../{zip_deploy.py => net_deploy.py} | 109 +++++++++++++++--- .../update_manager/update_manager.py | 9 +- 4 files changed, 112 insertions(+), 23 deletions(-) rename moonraker/components/update_manager/{zip_deploy.py => net_deploy.py} (79%) diff --git a/moonraker/components/update_manager/app_deploy.py b/moonraker/components/update_manager/app_deploy.py index 81fb59edd..0b61c8e6a 100644 --- a/moonraker/components/update_manager/app_deploy.py +++ b/moonraker/components/update_manager/app_deploy.py @@ -131,11 +131,7 @@ def _configure_dependencies( if self.py_exec is not None: self.python_reqs = self.path.joinpath(config.get("requirements")) self._verify_path(config, 'requirements', self.python_reqs) - deps = config.get("system_dependencies", None) - if deps is not None: - self.system_deps_json = self.path.joinpath(deps).resolve() - self._verify_path(config, 'system_dependencies', self.system_deps_json) - else: + if not self._configure_sysdeps(config): # Fall back on deprecated "install_script" option if dependencies file # not present install_script = config.get('install_script', None) @@ -143,6 +139,14 @@ def _configure_dependencies( self.install_script = self.path.joinpath(install_script).resolve() self._verify_path(config, 'install_script', self.install_script) + def _configure_sysdeps(self, config: ConfigHelper) -> bool: + deps = config.get("system_dependencies", None) + if deps is not None: + self.system_deps_json = self.path.joinpath(deps).resolve() + self._verify_path(config, 'system_dependencies', self.system_deps_json) + return True + return False + def _configure_managed_services(self, config: ConfigHelper) -> None: svc_default = [] if config.getboolean("is_system_service", True): diff --git a/moonraker/components/update_manager/common.py b/moonraker/components/update_manager/common.py index 3d6f3b338..64f60eb69 100644 --- a/moonraker/components/update_manager/common.py +++ b/moonraker/components/update_manager/common.py @@ -50,6 +50,7 @@ class AppType(ExtendedEnum): GIT_REPO = 3 ZIP = 4 PYTHON = 5 + EXECUTABLE = 6 @classmethod def detect(cls, app_path: Union[str, pathlib.Path, None] = None): @@ -73,7 +74,7 @@ def valid_types(cls) -> List[AppType]: def supported_channels(self) -> List[Channel]: if self == AppType.NONE: return [] - elif self in [AppType.WEB, AppType.ZIP]: + elif self in [AppType.WEB, AppType.ZIP, AppType.EXECUTABLE]: return [Channel.STABLE, Channel.BETA] # type: ignore else: return list(Channel) diff --git a/moonraker/components/update_manager/zip_deploy.py b/moonraker/components/update_manager/net_deploy.py similarity index 79% rename from moonraker/components/update_manager/zip_deploy.py rename to moonraker/components/update_manager/net_deploy.py index aae6d12f8..7247af3aa 100644 --- a/moonraker/components/update_manager/zip_deploy.py +++ b/moonraker/components/update_manager/net_deploy.py @@ -1,4 +1,4 @@ -# Zip Application Deployment implementation +# Net Hosted Application Deployment implementation # # Copyright (C) 2024 Eric Callahan # @@ -9,6 +9,7 @@ import shutil import zipfile import logging +import stat from .app_deploy import AppDeploy from .common import Channel, AppType from ...utils import source_info @@ -29,7 +30,7 @@ from ...confighelper import ConfigHelper from ..file_manager.file_manager import FileManager -class ZipDeploy(AppDeploy): +class NetDeploy(AppDeploy): def __init__(self, config: ConfigHelper) -> None: super().__init__(config, "Zip Application") self._configure_path(config, False) @@ -39,8 +40,13 @@ def __init__(self, config: ConfigHelper) -> None: self._configure_managed_services(config) elif self.type == AppType.WEB: self.prefix = f"Web Client {self.name}: " + elif self.type == AppType.EXECUTABLE: + self.prefix = f"Executable {self.name}: " + self._configure_sysdeps(config) + self._configure_managed_services(config) self.repo = config.get('repo').strip().strip("/") self.owner, self.project_name = self.repo.split("/", 1) + self.asset_name: Optional[str] = None self.persistent_files: List[str] = [] self.warnings: List[str] = [] self.anomalies: List[str] = [] @@ -56,6 +62,10 @@ def __init__(self, config: ConfigHelper) -> None: self._configure_persistent_files(config) def _configure_persistent_files(self, config: ConfigHelper) -> None: + if self.type == AppType.EXECUTABLE: + # executable types do not wipe the entire directory, + # so no need for persistent files + return pfiles = config.getlist('persistent_files', None) if pfiles is not None: self.persistent_files = [pf.strip("/") for pf in pfiles] @@ -105,6 +115,7 @@ async def _validate_release_info(self) -> None: project_name = uinfo["project_name"] owner = uinfo["project_owner"] self.version = uinfo["version"] + self.asset_name = uinfo.get("asset_name", None) except Exception: logging.exception("Failed to load release_info.json.") else: @@ -119,6 +130,22 @@ async def _validate_release_info(self) -> None: self.repo = detected_repo self.owner = owner self.project_name = project_name + if self.type == AppType.EXECUTABLE: + if self.asset_name is None: + self.warnings.append( + "Executable types require the 'asset_name' field in " + "release_info.json" + ) + self._is_valid = False + else: + fname = self.asset_name + exec_file = self.path.joinpath(fname) + if not exec_file.exists(): + self.warnings.append( + f"File {fname} not found in configured path for " + "executable type" + ) + self._is_valid = False elif self.type == AppType.WEB: version_path = self.path.joinpath(".version") if version_path.is_file(): @@ -183,7 +210,7 @@ async def initialize(self) -> Dict[str, Any]: dl_info: List[Any] = storage.get('dl_info', ["?", "?", 0]) self.dl_info = cast(Tuple[str, str, int], tuple(dl_info)) if not self.needs_refresh(): - self._log_zipapp_info() + self._log_app_info() return storage def get_persistent_data(self) -> Dict[str, Any]: @@ -204,7 +231,7 @@ async def refresh(self) -> None: await self._get_remote_version() except Exception: logging.exception("Error Refreshing Client") - self._log_zipapp_info() + self._log_app_info() self._save_state() async def _fetch_github_version( @@ -254,14 +281,22 @@ async def _get_remote_version(self) -> None: if not result: return self.remote_version = result.get('name', "?") - release_asset: Dict[str, Any] = result.get('assets', [{}])[0] + assets: List[Dict[str, Any]] = result.get("assets", [{}]) + release_asset: Dict[str, Any] = assets[0] if assets else {} + if self.asset_name is not None: + for asset in assets: + if asset.get("name", "") == self.asset_name: + release_asset = asset + break + else: + logging.info(f"Asset '{self.asset_name}' not found") dl_url: str = release_asset.get('browser_download_url', "?") content_type: str = release_asset.get('content_type', "?") size: int = release_asset.get('size', 0) self.dl_info = (dl_url, content_type, size) self._is_prerelease = result.get('prerelease', False) - def _log_zipapp_info(self): + def _log_app_info(self): warn_str = "" if self.warnings or self.anomalies: warn_str = "\nWarnings:\n" @@ -314,6 +349,46 @@ def _extract_release( dest_dir.mkdir(parents=True, exist_ok=True) shutil.move(str(src_path), str(dest_path)) + def _set_exec_perms(self, exec: pathlib.Path) -> None: + req_perms = stat.S_IXUSR | stat.S_IXGRP + kest_perms = stat.S_IMODE(exec.stat().st_mode) + if req_perms & kest_perms != req_perms: + try: + exec.chmod(kest_perms | req_perms) + except OSError: + logging.exception( + f"Failed to set executable permission for file {exec}" + ) + + async def _finalize_executable(self, tmp_file: pathlib.Path, new_ver: str) -> None: + if not self.type == AppType.EXECUTABLE: + return + # Remove existing binary + exec_path = self.path.joinpath(tmp_file.name) + if exec_path.is_file(): + exec_path.unlink() + eventloop = self.server.get_event_loop() + # move download to the configured path + dest = await eventloop.run_in_thread( + shutil.move, str(tmp_file), str(self.path) + ) + dest_path = pathlib.Path(dest) + # give file executable permissions + self._set_exec_perms(dest_path) + # Update release_info.json. This is required as executable distributions + # can't be bundled with release info. + rinfo = self.path.joinpath("release_info.json") + if not rinfo.is_file(): + return + eventloop = self.server.get_event_loop() + # If the new version does not match the version in release_info.json, + # update it. + data = await eventloop.run_in_thread(rinfo.read_text) + uinfo: Dict[str, Any] = jsonw.loads(data) + if uinfo["version"] != new_ver: + uinfo["version"] = new_ver + await eventloop.run_in_thread(rinfo.write_bytes, jsonw.dumps(uinfo)) + async def update( self, rollback_info: Optional[Tuple[str, str, int]] = None, @@ -327,6 +402,7 @@ async def update( if rollback_info is not None: dl_url, content_type, size = rollback_info start_msg = "Rolling Back..." if not is_recover else "Recovering..." + new_ver = self.rollback_version if not is_recover else self.version else: if self.remote_version == "?": await self._get_remote_version() @@ -334,6 +410,7 @@ async def update( raise self.server.error( f"{self.prefix}Unable to locate update" ) + new_ver = self.remote_version dl_url, content_type, size = self.dl_info if self.version == self.remote_version: # Already up to date @@ -346,12 +423,15 @@ async def update( self.notify_status(start_msg) self.notify_status("Downloading Release...") dep_info: Optional[Dict[str, Any]] = None - if self.type == AppType.ZIP: + if self.type in (AppType.ZIP, AppType.EXECUTABLE): dep_info = await self._collect_dependency_info() td = await self.cmd_helper.create_tempdir(self.name, "app") try: tempdir = pathlib.Path(td.name) - temp_download_file = tempdir.joinpath(f"{self.name}.zip") + if self.asset_name is not None: + temp_download_file = tempdir.joinpath(self.asset_name) + else: + temp_download_file = tempdir.joinpath(f"{self.name}.zip") temp_persist_dir = tempdir.joinpath(self.name) client = self.cmd_helper.get_http_client() await client.download_file( @@ -361,19 +441,22 @@ async def update( self.notify_status( f"Download Complete, extracting release to '{self.path}'" ) - await event_loop.run_in_thread( - self._extract_release, temp_persist_dir, temp_download_file - ) + if self.type == AppType.EXECUTABLE: + await self._finalize_executable(temp_download_file, new_ver) + else: + await event_loop.run_in_thread( + self._extract_release, temp_persist_dir, temp_download_file + ) finally: await event_loop.run_in_thread(td.cleanup) if dep_info is not None: await self._update_dependencies(dep_info, force_dep_update) - self.version = self.remote_version + self.version = new_ver await self._validate_release_info() if self._is_valid and rollback_info is None: self.rollback_version = current_version self.rollback_repo = self.repo - self._log_zipapp_info() + self._log_app_info() self._save_state() await self.restart_service() msg = "Update Finished..." if rollback_info is None else "Rollback Complete" diff --git a/moonraker/components/update_manager/update_manager.py b/moonraker/components/update_manager/update_manager.py index 6d1952ec5..533a29172 100644 --- a/moonraker/components/update_manager/update_manager.py +++ b/moonraker/components/update_manager/update_manager.py @@ -15,7 +15,7 @@ from .base_deploy import BaseDeploy from .app_deploy import AppDeploy from .git_deploy import GitDeploy -from .zip_deploy import ZipDeploy +from .net_deploy import NetDeploy from .python_deploy import PythonDeploy from .system_deploy import PackageDeploy from ...common import RequestType @@ -57,10 +57,11 @@ def get_deploy_class( ) -> Union[Type[BaseDeploy], _T]: key = AppType.from_string(app_type) if isinstance(app_type, str) else app_type _deployers = { - AppType.WEB: ZipDeploy, + AppType.WEB: NetDeploy, AppType.GIT_REPO: GitDeploy, - AppType.ZIP: ZipDeploy, - AppType.PYTHON: PythonDeploy + AppType.ZIP: NetDeploy, + AppType.PYTHON: PythonDeploy, + AppType.EXECUTABLE: NetDeploy } return _deployers.get(key, default) From bda03dcf29be1c1796c7a710a10eb68fc1158325 Mon Sep 17 00:00:00 2001 From: Eric Callahan Date: Wed, 22 Jan 2025 14:11:24 -0500 Subject: [PATCH 05/12] file_manager: emit event after gcode metadata has been processed Signed-off-by: Eric Callahan --- moonraker/components/file_manager/file_manager.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/moonraker/components/file_manager/file_manager.py b/moonraker/components/file_manager/file_manager.py index a969f1e0d..63bc576a1 100644 --- a/moonraker/components/file_manager/file_manager.py +++ b/moonraker/components/file_manager/file_manager.py @@ -2526,6 +2526,9 @@ async def _process_metadata_update(self) -> None: logging.exception("Error running extract_metadata.py") retries -= 1 else: + await self.server.send_event( + "file_manager:metadata_processed", fname + ) break else: if ufp_path is None: From 7f1907beb383f2383b5030ca211008bc48fed21d Mon Sep 17 00:00:00 2001 From: Eric Callahan Date: Sat, 8 Feb 2025 05:48:49 -0500 Subject: [PATCH 06/12] python_deploy: enable the eager pip update strategy Attempt to update all dependencies of a python package to the latest version compatible with its requirement specifier. Signed-off-by: Eric Callahan --- moonraker/components/update_manager/python_deploy.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/moonraker/components/update_manager/python_deploy.py b/moonraker/components/update_manager/python_deploy.py index 3138e55c6..f4258ef2b 100644 --- a/moonraker/components/update_manager/python_deploy.py +++ b/moonraker/components/update_manager/python_deploy.py @@ -351,6 +351,7 @@ async def update(self, rollback: bool = False) -> bool: self.pip_cmd, self.server, self.cmd_helper.notify_update_response ) current_ref = self.current_version.tag + pip_args = "install -U --upgrade-strategy eager" if self.source == PackageSource.PIP: # We can't depend on the SHA being available for PyPI packages, # so we must compare versions @@ -359,7 +360,7 @@ async def update(self, rollback: bool = False) -> bool: self.upstream_version <= self.current_version ): return False - pip_args = f"install -U {project_name}" + pip_args = f"{pip_args} {project_name}" if rollback: pip_args += f"=={self.rollback_ref}" elif self.source == PackageSource.GITHUB: @@ -374,7 +375,7 @@ async def update(self, rollback: bool = False) -> bool: repo += f"@{self.primary_branch}" else: repo += f"@{self.upstream_version.tag}" - pip_args = f"install -U '{project_name} @ git+https://github.com/{repo}'" + pip_args = f"{pip_args} '{project_name} @ git+https://github.com/{repo}'" else: raise self.server.error("Cannot update, package source is unknown") await self._update_pip(pip_exec) From c7c2bb20d9a38a327e72d03919cc4b297ac8fbcc Mon Sep 17 00:00:00 2001 From: Eric Callahan Date: Sat, 8 Feb 2025 11:39:44 -0500 Subject: [PATCH 07/12] python_deploy: fix rollback procedure Signed-off-by: Eric Callahan --- .../update_manager/python_deploy.py | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/moonraker/components/update_manager/python_deploy.py b/moonraker/components/update_manager/python_deploy.py index f4258ef2b..3f49aa8ef 100644 --- a/moonraker/components/update_manager/python_deploy.py +++ b/moonraker/components/update_manager/python_deploy.py @@ -63,6 +63,7 @@ def __init__(self, config: ConfigHelper) -> None: self.current_sha: str = "?" self.upstream_version: PyVersion = self.current_version self.upstream_sha: str = "?" + self.rollback_version: PyVersion = self.current_version self.rollback_ref: str = "?" self.warnings: List[str] = [] package_info = load_distribution_info(self.virtualenv, self.project_name) @@ -77,6 +78,7 @@ async def initialize(self) -> Dict[str, Any]: self.upstream_sha = storage.get("upstream_commit", "?") self.upstream_version = PyVersion(storage.get("upstream_version", "?")) self.rollback_ref = storage.get("rollback_ref", "?") + self.rollback_version = PyVersion(storage.get("rollback_version", "?")) if not self.needs_refresh(): self._log_package_info() return storage @@ -86,6 +88,7 @@ def get_persistent_data(self) -> Dict[str, Any]: storage["upstream_commit"] = self.upstream_sha storage["upstream_version"] = self.upstream_version.full_version storage["rollback_ref"] = self.rollback_ref + storage["rollback_version"] = self.rollback_version.full_version return storage def get_update_status(self) -> Dict[str, Any]: @@ -98,6 +101,7 @@ def get_update_status(self) -> Dict[str, Any]: "repo_name": self.repo_name, "version": self.current_version.short_version, "remote_version": self.upstream_version.short_version, + "rollback_version": self.rollback_version.short_version, "current_hash": self.current_sha, "remote_hash": self.upstream_sha, "is_dirty": self.git_version.dirty, @@ -176,7 +180,8 @@ def _update_current_version(self, package_info: PackageInfo) -> bool: release_info = package_info.release_info metadata = package_info.metadata if release_info is not None: - self.current_sha = release_info.get("commit_sha", self.current_sha) + if self.current_sha == "?": + self.current_sha = release_info.get("commit_sha", "?") self.git_version = GitVersion(release_info.get("git_version", "?")) pkg_verson = release_info.get("package_version", "") if "Version" in metadata: @@ -343,29 +348,26 @@ async def update(self, rollback: bool = False) -> bool: if self.extras is not None: project_name = f"{project_name}[{self.extras}]" assert self.pip_cmd is not None - pip_args: str - if not self.upstream_version.is_valid_version(): - # Can't update without a valid upstream + current_version = self.current_version + current_ref = self.current_version.tag + install_ver = self.rollback_version if rollback else self.upstream_version + if ( + not install_ver.is_valid_version() or + (current_version.is_valid_version() and current_version == install_ver) + ): + # Invalid install version or requested version already installed return False pip_exec = pip_utils.AsyncPipExecutor( self.pip_cmd, self.server, self.cmd_helper.notify_update_response ) - current_ref = self.current_version.tag pip_args = "install -U --upgrade-strategy eager" if self.source == PackageSource.PIP: # We can't depend on the SHA being available for PyPI packages, # so we must compare versions - if ( - self.current_version.is_valid_version() and - self.upstream_version <= self.current_version - ): - return False pip_args = f"{pip_args} {project_name}" if rollback: pip_args += f"=={self.rollback_ref}" elif self.source == PackageSource.GITHUB: - if self.current_sha == self.upstream_sha: - return False repo = f"{self.repo_owner}/{self.repo_name}" if rollback: repo += f"@{self.rollback_ref}" @@ -385,9 +387,10 @@ async def update(self, rollback: bool = False) -> bool: await pip_exec.call_pip(pip_args, 3600, sys_env_vars=self.pip_env_vars) await self._update_local_state() if not rollback: + self.rollback_version = current_version self.rollback_ref = current_ref - self.upstream_sha = self.current_sha - self.upstream_version = self.current_version + self.upstream_sha = self.current_sha + self.upstream_version = self.current_version await self._update_sys_deps(sys_deps) self._log_package_info() self._save_state() @@ -401,7 +404,7 @@ async def recover( pass async def rollback(self) -> bool: - if self.rollback_ref == "?": + if self.rollback_ref == "?" or not self.rollback_version.is_valid_version(): return False await self.update(rollback=True) return True @@ -427,5 +430,6 @@ def _log_package_info(self) -> None: f"Upstream Version: {self.upstream_version.short_version}\n" f"Upstream Commit SHA: {self.upstream_sha}\n" f"Converted Git Version: {self.git_version}\n" + f"Rollback Version: {self.rollback_version}\n" f"Rollback Ref: {self.rollback_ref}\n" ) From 3ec968d873d7f57d1a5b7f19ce23338ff5369d6e Mon Sep 17 00:00:00 2001 From: Eric Callahan Date: Sat, 8 Feb 2025 12:23:24 -0500 Subject: [PATCH 08/12] python_deploy: update local state on refresh Signed-off-by: Eric Callahan --- moonraker/components/update_manager/python_deploy.py | 1 + 1 file changed, 1 insertion(+) diff --git a/moonraker/components/update_manager/python_deploy.py b/moonraker/components/update_manager/python_deploy.py index 3f49aa8ef..66aed5f6c 100644 --- a/moonraker/components/update_manager/python_deploy.py +++ b/moonraker/components/update_manager/python_deploy.py @@ -245,6 +245,7 @@ async def _update_local_state(self) -> None: async def refresh(self) -> None: try: + await self._update_local_state() if self.source == PackageSource.PIP: await self._refresh_pip() elif self.source == PackageSource.GITHUB: From f2c564cfb731f1609772fd3c34ec489c7ec2dc78 Mon Sep 17 00:00:00 2001 From: Eric Callahan Date: Wed, 28 Jun 2023 06:30:06 -0400 Subject: [PATCH 09/12] analysis: initial implementation Adds support for GCode file time analysis using Klipper Estimator. Signed-off-by: Eric Callahan --- moonraker/components/analysis.py | 448 +++++++++++++++++++++++++++++++ 1 file changed, 448 insertions(+) create mode 100644 moonraker/components/analysis.py diff --git a/moonraker/components/analysis.py b/moonraker/components/analysis.py new file mode 100644 index 000000000..a35343dc8 --- /dev/null +++ b/moonraker/components/analysis.py @@ -0,0 +1,448 @@ +# Printer GCode Analysis using Klipper Estimator +# +# Copyright (C) 2025 Eric Callahan +# +# This file may be distributed under the terms of the GNU GPLv3 license + +from __future__ import annotations +import sys +import os +import platform +import pathlib +import stat +import re +import logging +import asyncio +from ..common import RequestType +from ..utils import json_wrapper as jsonw +from typing import ( + TYPE_CHECKING, + Union, + Optional, + Dict, + Any, + Tuple +) + +if TYPE_CHECKING: + from ..confighelper import ConfigHelper + from ..common import WebRequest + from .update_manager.update_manager import UpdateManager + from .klippy_connection import KlippyConnection + from .authorization import Authorization + from .file_manager.file_manager import FileManager + from .machine import Machine + from .shell_command import ShellCommandFactory + from .http_client import HttpClient + StrOrPath = Union[str, pathlib.Path] + +ESTIMATOR_URL = ( + "https://github.com/Annex-Engineering/klipper_estimator/" + "releases/latest/download/{asset}" +) +UPDATE_CONFIG = { + "type": "executable", + "channel": "stable", + "repo": "Annex-Engineering/klipper_estimator", + "is_system_service": "False", + "path": "" +} +RELEASE_INFO = { + "project_name": "klipper_estimator", + "project_owner": "Annex-Engineering", + "version": "", + "asset_name": "" +} + +class GcodeAnalysis: + def __init__(self, config: ConfigHelper) -> None: + self.server = config.get_server() + self.cmd_lock = asyncio.Lock() + self.file_manger: FileManager = self.server.lookup_component("file_manager") + data_path = self.server.get_app_args()["data_path"] + tool_folder = pathlib.Path(data_path).joinpath("tools/klipper_estimator") + if not tool_folder.exists(): + tool_folder.mkdir(parents=True) + self.estimator_timeout = config.getint("estimator_timeout", 600) + self.auto_dump_defcfg = config.getboolean("auto_dump_default_config", False) + self.default_config = tool_folder.joinpath("default_estimator_cfg.json") + self.estimator_config = self.default_config + est_config = config.get("estimator_config", None) + if est_config is not None: + est_path = self.file_manger.get_full_path("config", est_config.strip("/")) + if ".." in est_path.parts: + raise config.error( + "Value for option 'estimator_config' must not contain " + "a '..' segment" + ) + if not est_path.exists(): + raise config.error( + f"File '{est_config}' does not exist in 'config' root" + ) + self.estimator_config = est_path + if config.getboolean("enable_auto_analysis", False): + self.server.register_event_handler( + "file_manager:metadata_processed", self._on_metadata_processed + ) + self.estimator_path: pathlib.Path | None = None + self.estimator_ready: bool = False + self.estimator_version: str = "?" + pltform_choices = ["rpi", "linux", "osx", "auto"] + pltform = config.getchoice("platform", pltform_choices, default_key="auto") + if pltform == "auto": + auto_pfrm = self._detect_platform() + if auto_pfrm is not None: + self.estimator_path = tool_folder.joinpath( + f"klipper_estimator_{auto_pfrm}" + ) + else: + exec_name = f"klipper_estimator_{pltform}" + self.estimator_path = tool_folder.joinpath(exec_name) + enable_updates = config.getboolean("enable_estimator_updates", False) + self.updater_registered: bool = False + if enable_updates: + if self.estimator_path is None: + logging.info( + "Klipper estimator platform not detected, updates disabled" + ) + elif not config.has_section("update_manager"): + logging.info("Update Manager not configured, updates disabled") + else: + try: + um: UpdateManager + um = self.server.load_component(config, "update_manager") + updater_cfg = UPDATE_CONFIG + updater_cfg["path"] = str(tool_folder) + um.register_updater("klipper_estimator", updater_cfg) + except self.server.error: + logging.exception("Klipper Estimator update registration failed") + else: + self.updater_registered = True + if not self.updater_registered: + # Add reserved path when updates are disabled + self.file_manger.add_reserved_path("analysis", tool_folder, False) + self.server.register_endpoint( + "/server/analysis/status", RequestType.GET, + self._handle_status_request + ) + self.server.register_endpoint( + "/server/analysis/estimate", RequestType.POST, + self._handle_estimation_request + ) + self.server.register_endpoint( + "/server/analysis/dump_config", RequestType.POST, + self._handle_dump_cfg_request + ) + self.server.register_event_handler( + "server:klippy_ready", self._on_klippy_ready + ) + + @property + def estimator_version_tuple(self) -> Tuple[int, ...]: + if self.estimator_version in ["?", ""]: + return tuple() + ver_string = self.estimator_version + if ver_string[0] == "v": + ver_string = ver_string[1:] + return tuple([int(p) for p in ver_string.split(".")]) + + async def _on_klippy_ready(self) -> None: + if not self.estimator_ready: + return + if self.auto_dump_defcfg or not self.default_config.exists(): + logging.info( + "Dumping default Klipper Estimator configuration to " + f"{self.default_config}" + ) + eventloop = self.server.get_event_loop() + eventloop.create_task(self._dump_estimator_config(self.default_config)) + + async def _on_metadata_processed(self, rel_gc_path: str) -> None: + if not self.estimator_ready: + logging.info("Klipper Estimator not available") + return + try: + full_path = self.file_manger.get_full_path("gcodes", rel_gc_path) + ret = await self.estimate_file(full_path) + self._update_metadata_est_time(rel_gc_path, ret) + except self.server.error: + logging.exception("Klipper Estimator failure") + + def _update_metadata_est_time( + self, gc_fname: str, est_data: Dict[str, Any] + ) -> None: + md_storage = self.file_manger.get_metadata_storage() + gc_metadata = md_storage.get(gc_fname, None) + if gc_metadata is not None: + if "slicer_estimated_time" not in gc_metadata: + prev_est = gc_metadata.get("estimated_time", 0) + gc_metadata["slicer_estimated_time"] = prev_est + gc_metadata["estimated_time"] = round(est_data["total_time"], 2) + md_storage.insert(gc_fname, gc_metadata) + + async def component_init(self) -> None: + if self.estimator_path is None: + return + if not self.estimator_path.exists(): + # Download Klipper Estimator + await self._download_klipper_estimator(self.estimator_path) + if not self._check_estimator_perms(self.estimator_path): + self.server.add_warning( + "[analysis]: Moonraker lacks permission to execute Klipper Estimator", + "analysis_permission" + ) + return + else: + await self._detect_estimator_version() + if self.estimator_version == "?": + logging.info("Failed to initialize Klipper Estimator") + else: + await self._check_release_info(self.estimator_path) + self.estimator_ready = True + logging.info( + f"Klipper Estimator Version {self.estimator_version} detected" + ) + + def _detect_platform(self) -> Optional[str]: + # Detect OS + if sys.platform.startswith("darwin"): + return "osx" + elif sys.platform.startswith("linux"): + # Get architecture + arch: str = platform.machine() + if not arch: + self.server.add_warning( + "[analysis]: Failed to detect CPU architecture. " + "Manual configuration of the 'platform' option is required.", + "analysis_estimator" + ) + return None + if arch == "x86_64": + return "linux" + elif arch in ("armv7l", "aarch64"): + # TODO: Other platforms may work, not sure + return "rpi" + else: + self.server.add_warning( + f"[analysis]: Unsupported CPU architecture '{arch}'. " + "Manual configuration of the 'platform' option is required.", + "analysis_estimator" + ) + return None + else: + self.server.add_warning( + f"[analysis]: Unsupported platform '{sys.platform}'. " + "Manual configuration of the 'platform' option is required.", + "analysis_estimator" + ) + + async def _download_klipper_estimator(self, estimator_path: pathlib.Path) -> None: + """ + Download Klipper Estimator, set executable permissions, and generate + the release_info.json file + """ + async with self.cmd_lock: + est_name = estimator_path.name + logging.info(f"Downloading latest {est_name}...") + url = ESTIMATOR_URL.format(asset=est_name) + http_client: HttpClient = self.server.lookup_component("http_client") + await http_client.download_file( + url, "application/octet-stream", estimator_path + ) + logging.info("Klipper Estimator download complete.") + + async def _detect_estimator_version(self) -> None: + cmd = f"{self.estimator_path} --version" + scmd: ShellCommandFactory = self.server.lookup_component("shell_command") + ret = await scmd.exec_cmd(cmd, timeout=10.) + ver_match = re.match(r"klipper_estimator (v?\d+(?:\.\d+)*)", ret) + if ver_match is None: + self.estimator_version = "?" + else: + self.estimator_version = ver_match.group(1) + + def _check_estimator_perms(self, estimator_path: pathlib.Path) -> bool: + req_perms = stat.S_IXUSR | stat.S_IXGRP + kest_perms = stat.S_IMODE(estimator_path.stat().st_mode) + if req_perms & kest_perms != req_perms: + logging.info("Setting excutable permissions for Klipper Estimator...") + try: + estimator_path.chmod(kest_perms | req_perms) + except OSError: + logging.exception( + "Failed to set Klipper Estimator Permissions" + ) + return os.access(estimator_path, os.X_OK) + + async def _check_release_info(self, estimator_path: pathlib.Path) -> None: + rinfo_file = estimator_path.parent.joinpath("release_info.json") + if rinfo_file.is_file(): + return + logging.info("Creating release_info.json for Klipper Estimator...") + rinfo = dict(RELEASE_INFO) + rinfo["version"] = self.estimator_version + rinfo["asset_name"] = estimator_path.name + eventloop = self.server.get_event_loop() + await eventloop.run_in_thread(rinfo_file.write_bytes, jsonw.dumps(rinfo)) + if self.updater_registered: + logging.info("Refreshing Klipper Estimator Updater Instance...") + um: UpdateManager = self.server.lookup_component("update_manager") + eventloop.create_task(um.refresh_updater("klipper_estimator", True)) + + def _get_moonraker_url(self) -> str: + machine: Machine = self.server.lookup_component("machine") + host_info = self.server.get_host_info() + host_addr: str = host_info["address"] + if host_addr.lower() in ["all", "0.0.0.0"]: + address = "127.0.0.1" + elif host_addr.lower() == "::": + address = "::1" + else: + address = machine.public_ip + if not address: + address = f"{host_info['hostname']}.local" + elif ":" in address: + # ipv6 address + address = f"[{address}]" + port = host_info["port"] + return f"http://{address}:{port}/" + + def _gen_estimate_cmd( + self, gc_path: pathlib.Path, est_cfg_path: pathlib.Path + ) -> str: + if self.estimator_path is None or not self.estimator_ready: + raise self.server.error("Klipper Estimator not available") + if not est_cfg_path.exists(): + raise self.server.error( + f"Klipper Estimator config {est_cfg_path.name} does not exist" + ) + cmd = str(self.estimator_path) + escaped_cfg = str(est_cfg_path).replace("\"", "\\\"") + cmd = f"{cmd} --config_file \"{escaped_cfg}\"" + escaped_gc = str(gc_path).replace("\"", "\\\"") + cmd = f"{cmd} estimate -f json \"{escaped_gc}\"" + return cmd + + def _gen_dump_cmd(self) -> str: + if self.estimator_path is None or not self.estimator_ready: + raise self.server.error("Klipper Estimator not available") + return f"{self.estimator_path} {self._gen_url_opts()} dump-config" + + def _gen_url_opts(self) -> str: + url = self._get_moonraker_url() + opts = f"--config_moonraker_url {url}" + auth: Optional[Authorization] + auth = self.server.lookup_component("authorization", None) + api_key = auth.get_api_key() if auth is not None else None + if api_key is not None: + opts = f"{opts} --config_moonraker_api_key {api_key}" + return opts + + async def _dump_estimator_config(self, dest: pathlib.Path) -> Dict[str, Any]: + async with self.cmd_lock: + kconn: KlippyConnection = self.server.lookup_component("klippy_connection") + scmd: ShellCommandFactory = self.server.lookup_component("shell_command") + eventloop = self.server.get_event_loop() + if not kconn.is_ready(): + raise self.server.error( + "Klipper Estimator cannot dump configuration, Klippy not ready", + 504 + ) + dump_cmd = self._gen_dump_cmd() + try: + ret = await scmd.exec_cmd( + dump_cmd, timeout=10., log_complete=False, log_stderr=True + ) + await eventloop.run_in_thread(dest.write_text, ret) + except scmd.error: + raise self.server.error( + "Klipper Estimator dump-config failed", 500 + ) from None + return jsonw.loads(ret) + + async def estimate_file( + self, gc_path: pathlib.Path, est_config: Optional[pathlib.Path] = None + ) -> Dict[str, Any]: + async with self.cmd_lock: + if est_config is None: + # Fall back to estimator config specified in the [analysis] + # section. + est_config = self.estimator_config + if not est_config.is_file(): + raise self.server.error( + f"Estimator config file '{est_config}' does not exist" + ) + if not gc_path.is_file(): + raise self.server.error(f"GCode File '{gc_path}' does not exist") + scmd: ShellCommandFactory = self.server.lookup_component("shell_command") + est_cmd = self._gen_estimate_cmd(gc_path, est_config) + ret = await scmd.exec_cmd(est_cmd, self.estimator_timeout) + data = jsonw.loads(ret) + return data["sequences"][0] + + async def _handle_status_request( + self, web_request: WebRequest + ) -> Dict[str, Any]: + est_exec = "unknown" + if self.estimator_path is not None: + est_exec = self.estimator_path.name + is_default = self.estimator_config == self.default_config + return { + "estimator_executable": est_exec, + "estimator_ready": self.estimator_ready, + "estimator_version": self.estimator_version, + "estimator_config_exists": self.estimator_config.exists(), + "using_default_config": is_default + } + + async def _handle_estimation_request( + self, web_request: WebRequest + ) -> Dict[str, Any]: + gcode_file = web_request.get_str("filename").strip("/") + update_metadata = web_request.get_boolean("update_metadata", False) + estimator_config = web_request.get_str("estimator_config", None) + gc_path = self.file_manger.get_full_path("gcodes", gcode_file) + if not gc_path.is_file(): + raise self.server.error( + f"GCode File '{gcode_file}' does not exit in gcodes path" + ) + est_cfg_path = self.estimator_config + if estimator_config is not None: + estimator_config = estimator_config.strip("/") + est_cfg_path = self.file_manger.get_full_path("config", estimator_config) + if ".." in est_cfg_path.parts: + raise self.server.error( + "Invalid value for param 'estimator_config', '..' segments " + "are not allowed" + ) + ret = await self.estimate_file(gc_path, est_cfg_path) + if update_metadata: + self._update_metadata_est_time(gcode_file, ret) + return ret + + async def _handle_dump_cfg_request( + self, web_request: WebRequest + ) -> Dict[str, Any]: + dest = web_request.get_str("dest_config", None) + root: str | None = None + if dest is not None: + root = "config" + dest = dest.strip("/") + if ".." in pathlib.Path(dest).parts: + raise self.server.error( + "Parameter 'dest_config' may not contain '..' parts" + ) + dest_config = self.file_manger.get_full_path("config", dest) + else: + dest = self.default_config.name + dest_config = self.default_config + result = await self._dump_estimator_config(dest_config) + return { + "dest_root": root, + "dest_config_path": dest, + "klipper_estimator_config": result + } + + +def load_component(config: ConfigHelper) -> GcodeAnalysis: + return GcodeAnalysis(config) From 2fcc542d8584264d928aa576c306d8575f49964b Mon Sep 17 00:00:00 2001 From: Eric Callahan Date: Sun, 9 Feb 2025 11:47:00 -0500 Subject: [PATCH 10/12] docs: update documentation with latest changes Signed-off-by: Eric Callahan --- docs/changelog.md | 5 + docs/configuration.md | 179 ++++++- docs/external_api/integrations.md | 847 ++++++++++++++++++++++++++++++ 3 files changed, 1007 insertions(+), 24 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index a73f8ea6d..a2f2f14e3 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog]. - **pip_utils**: Use the "upgrade" option when installing python packages. This will force upgrades to the latest version available as resolved by the requirement specifier. +- **python_deploy**: Use the "eager" dependency update strategy. - **wled**: Use the `async_serial` utility for serial comms. - **paneldue**: Use the `async_serial` utility for serial comms. - **scripts**: Update `fetch-apikey.sh` to query the SQL database @@ -38,6 +39,7 @@ The format is based on [Keep a Changelog]. ### Fixed - **python_deploy**: fix "dev" channel updates for GitHub sources. +- **python_deploy**: fix release rollbacks. - **mqtt**: Publish the result of the Klipper status subscription request. This fixes issues with MQTT clients missing the initial status updates after Klippy restarts. @@ -60,6 +62,9 @@ The format is based on [Keep a Changelog]. simply iterate over the values of the `version_info` object. - **python_deploy**: Add support for updating python packages with "extras" installed. +- **update_manager**: Add support for updating `executable` binaries. +- **analysis**: Initial support for gcode file time analysis using + [Klipper Estimator](https://github.com/Annex-Engineering/klipper_estimator). ## [0.9.3] - 2024-09-05 diff --git a/docs/configuration.md b/docs/configuration.md index 905b698e0..52594c42b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -519,6 +519,46 @@ in the response. Optional Components are only loaded if present in `moonraker.conf`. This includes components that may not have any configuration. +### `[analysis]` + +The `analysis` component enables gcode file time analysis using +[Klipper Estimator](https://github.com/Annex-Engineering/klipper_estimator). +When enabled, Moonraker will automatically download the `klipper_estimator` +binary and optionally create an [update manger](#update_manager) entry for it. + +```ini {title="Moonraker Config Specification"} +# moonraker.conf +platform: auto +# The platform flavor of Klipper Estimator to use. Must be a choice +# from rpi, linux, osx, and auto. Note that "rpi" supports armv7 and +# aarch64 linux platforms, whereas "linux" supports amd64 linux +# platform. The auto choice will attempt to automatically determine +# the correct platform. The default is auto. +estimator_config: +# A path relative to the "config" root specifying a config +# file to use for Klipper Estimator. The default is to +# use a configuration dumped with data retrieved from Klipper's +# current settings. +auto_dump_default_config: false +# When set to true the default configuration for Klipper Estimator +# will be dumped every time Klippy restarts. When false the +# configuration is only dumped if the default configuration file +# does not exist. The default is false. +estimator_timeout: 600 +# The maximum amount of time (in seconds) Klipper Esti+mator +# is given to process a gcode file before processing is +# aborted. The default is 600 seconds. +enable_auto_analysis: false +# When set to true Klipper Estimator will be used to perform a time +# analysis for gcode files immediately after metadata is processed. +# The "total_time" result will replace the existing "estimated_time" +# field in the gcode metadata. This automates the time analysis for +# any event that triggers metadata processing. Default is false. +enable_estimator_updates: false +# When true Moonraker will create and register an entry for +# klipper_estimator with the update manager. Default is false. +``` + ### `[ldap]` The `ldap` module may be used by `[authorization]` to perform user @@ -1879,6 +1919,9 @@ down into 4 basic types: See the note below in reference to unofficial extensions. - `zip`: This can be used to manage various extensions like the `git_repo` type, however its updates are deployed via zipped GitHub releases. +- `executable`: Like the `zip` type this can be used to manage applications + and extensions. An executable type must be a pre-built binary executable + file hosted on GitHub. - `python`: The python type can be used to update python applications installed using `pip` in a virtual environment. @@ -1893,27 +1936,57 @@ trackers without first reproducing the issue using pristine versions of Moonraker and/or Klipper. /// -#### Web type (front-end) configuration - -/// Note -Front-end developers that wish to deploy updates via Moonraker -should host releases on their GitHub repo. In the root of each -release a `release_info.json` file should be present. This -file must contain a JSON object with the following fields: - -- `project_name`: The name of the GitHub project -- `project_owner`: The User or Organization that owns the project -- `version`: The current release version - -For example, a `release_info.json` for Mainsail might contain the -following: -```json +#### The release_info.json file + +The `web`, `zip`, and `exectuable` types require that the install +folder contain a `release_info.json` file. This file contains +information the update manager uses to validate the local install. + +| Field | Description | +| --------------- | ---------------------------------------------------- | +| `project_name` | The name of the GitHub project hosting the software. | +| `project_owner` | The User or Organization that owns the project. | +| `version` | The version of the installed software. This should | +| | match the release tag on GitHub. |^ +| `asset_name` | The name of the asset to download on GitHub. This | +| | is optional for `web` and `zip` types, they will |^ +| | default to `{config_section_name}.zip`. The |^ +| | `executable` type **REQUIRES** this field. |^ +{ #release-info-json-spec } Release Info Specification + +```json {title="Web Type Release Info Example"} { "project_name": "mainsail", "project_owner": "mainsail-crew", "version": "v2.5.1" } ``` + +```json {title="Executable Type Release Info Example"} +{ + "project_name":"klipper_estimator", + "project_owner":"Annex-Engineering", + "version":"v3.7.3", + "asset_name":"klipper_estimator_rpi" +} +``` + +/// note +Moonraker automatically creates the above `release_info.json` +file for Klipper Estimator when the [analysis](#analysis) +section is configured in `moonraker.conf`. When the +`enable_updates` option is enabled Moonraker will register +Klipper Estimator with the update manager, so there is no +need to add a `[update manager klipper_estimator]` section +to the configuration. +/// + +#### Web type (front-end) configuration + +/// Note +Software using the `web` type should host their distribution +in a zip file as a GitHub release. The zip file MUST contain +[release_info.json](#the-release_infojson-file). /// ```ini {title="Moonraker Config Specification"} @@ -2106,17 +2179,21 @@ option must match the case of the systemd unit file. #### Zip Application Configuration +/// Note +Software using the `zip` type should host their distribution +in a zip file as a GitHub release. The zip file MUST contain +[release_info.json](#the-release_infojson-file). +/// + The `zip` type can be used to deploy zipped application updates through GitHub releases. They can be thought of as a combination of the `web` and `git_repo` -types. Like `web` types, zipped applications must include a `release_info.json` -file (see the [web type](#web-type-front-end-configuration) not for details). -In addition, `zip` types can be configured to update dependencies and manage +types. Like `web` types, the are GitHub hosted zip archives. Like `git_repo` +types, `zip` types can be configured to update dependencies and manage services. -The `zip` type is ideal for applications that need to be built before deployment. -The thing to keep in mind is that any application updated through Moonraker needs -either be cross-platform, or it needs to deploy binaries for multiple platforms -and be able to choose the correct one based on the system. +The `zip` type is ideal for applications that must bundle multiple files in +a release. If bundling executable files, keep in mind that Moonraker runs +on multiple architectures. ```ini {title="Moonraker Config Specification"} type: zip @@ -2152,6 +2229,54 @@ info_tags: # options. ``` +#### Executable Configuration + +/// Note +Software using the `executable` type should host their binaries +on GitHub as a release. The initial installer for the executable +MUST create [release_info.json](#the-release_infojson-file) in +the folder containing the executable. +/// + + +The `executable` type can be used to deploy pre-built executable binaries +through GitHub releases. Executable types can be installed as system services +and may specifiy OS package dependencies. + +Like `web` and `zip` types, `executuable` types must include a `release_info.json` +file (see the [web type](#web-type-front-end-configuration) not for details). +In addition, `executable` types can be configured to update dependencies and manage +services. + +The `executable` type is ideal for applications that need to be built before deployment. +The thing to keep in mind is that any application updated through Moonraker needs +either be cross-platform, or it needs to deploy binaries for multiple platforms +and be able to choose the correct one based on the system. + +```ini {title="Moonraker Config Specification"} +type: executable +channel: stable +# May be stable or beta. When beta is specified "pre-release" +# updates are available. The default is stable. +repo: +# This is the GitHub repo of the application, in the format of owner/repo_name. +path: +# The path to folder containing the executable on disk. This folder must contain a +# a previously installed application and a valid release_info.json file. +# The folder must not be located within a git repo and it must not be located +# within a path that Moonraker has reserved, ie: it cannot share a path with +# another extension. This parameter must be provided. +refresh_interval: +# This overrides the refresh_interval set in the primary [update_manager] +# section. +system_dependencies: +is_system_service: True +managed_services: +info_tags: +# See the git_repo type documentation for detailed descriptions of the above +# options. +``` + #### Python Application Configuration The `python` type can be used to update python applications installed via pip @@ -2201,7 +2326,13 @@ virtualenv: ~/pyapp ``` /// -##### The optional release_info file +##### The optional python release_info file + +/// note +This file has a different specification than the +[release_info.json](#the-release_infojson-file) +file required by other types. +/// Python applications may include a `release_info` file in the package folder that provides supplemental information for the application. The @@ -2226,7 +2357,7 @@ folder that provides supplemental information for the application. The For example, Moonraker's `release_info` looks similar to the following: -```json +```json {title="Python release_info example"} { "project_name": "moonraker", "package_name": "moonraker", diff --git a/docs/external_api/integrations.md b/docs/external_api/integrations.md index 2fa8b87af..afcf5c076 100644 --- a/docs/external_api/integrations.md +++ b/docs/external_api/integrations.md @@ -510,6 +510,853 @@ proxied directly. /// +## Klipper Estimator time analysis + +Moonraker's `analysis` component uses +[Klipper Estimator](https://github.com/Annex-Engineering/klipper_estimator) +to perform gcode file time analysis. The endpoints in this section are available +when the `[analysis]` section has been configured in `moonraker.conf`. + +### Get Analysis Status + +```{.http .apirequest title="HTTP Request"} +GET /server/analysis/status +``` + +```{.json .apirequest title="JSON-RPC Request"} +{ + "jsonrpc": "2.0", + "method": "server.analysis.status", + "id": 4654 +} +``` + +/// collapse-code +```{.json .apiresponse title="Example Response"} +{ + "estimator_executable": "klipper_estimator_rpi", + "estimator_ready": true, + "estimator_version": "v3.7.3", + "estimator_config_exists": true, + "using_default_config": false +} +``` +/// + +/// api-response-spec + open: True + +| Field | Type | Description | +| ------------------------- | :----: | -------------------------------------------- | +| `estimator_executable` | string | The name of the Klipper Estimator executable | +| | | file. |^ +| `estimator_ready` | bool | A value of `true` indicates that the Klipper | +| | | Estimator binary is present and successfully |^ +| | | reports its version. |^ +| `estimator_version` | string | The version reported by Klipper Estimator. | +| `estimator_config_exists` | bool | A value of `true` indicates that a valid | +| | | Klipper Estimator config file exists. |^ +| `using_default_config` | bool | Reports `true` when Klipper Estimator is | +| | | configured to use the default config. |^ + +//// note +When Klipper Estimator is first initialized Moonraker downloads the binary +and grants it executable permissions. A default configuration will be +dumped when Klippy reports `ready`. The default configuration will not +exist until Klippy is `ready` and available. +//// + +/// + +### Perform a time analysis + +```{.http .apirequest title="HTTP Request"} +POST /server/analysis/estimate +Content-Type: application/json + +{ + "filename": "my_file.gcode", + "estimator_config": "custom_estimator_cfg.json", + "update_metadata": false +} +``` + +```{.json .apirequest title="JSON-RPC Request"} +{ + "jsonrpc": "2.0", + "method": "server.analysis.estimate", + "params": { + "filename": "my_file.gcode", + "estimator_config": "custom_estimator_cfg.json", + "update_metadata": false + } + "id": 4654 +} +``` + +/// api-parameters + open: True + +| Name | Type | Default | Description | +| ------------------ | :----: | ------------------ | ----------------------------------------- | +| `filename` | string | **REQUIRED** | The path to the gcode file to perform | +| | | | a time estimate on. This should be a |^ +| | | | path relative to the `gcodes` root |^ +| | | | folder. |^ +| `estimator_config` | string | **CONFIG_DEFAULT** | The path to a Klipper Estimator config | +| | | | file, relative to the `config` root |^ +| | | | folder. When omitted the file configured |^ +| | | | in the `[analysis]` section of |^ +| | | | `moonraker.conf` or the default dumped |^ +| | | | config will be used. |^ +| `update_metadata` | bool | false | When set to `true` the `estimated_time` | +| | | | field of the gcode file's metadata will |^ +| | | | be overwritten with the `total_time` |^ +| | | | result from Klipper Estimator. |^ + +/// + +/// collapse-code +```{.json .apiresponse title="Example Response"} +{ + "total_time": 3086.8131575260686, + "total_distance": 63403.85014049082, + "total_extrude_distance": 2999.883480000007, + "max_flow": 7.593973828405062, + "max_speed": 180, + "num_moves": 19068, + "total_z_time": 122.51358592092325, + "total_output_time": 2789.122405609832, + "total_travel_time": 257.9946362351847, + "total_extrude_only_time": 39.446115681036936, + "phase_times": { + "acceleration": 360.966527738102, + "cruise": 2365.24977575805, + "deceleration": 360.3468540299323 + }, + "kind_times": { + "Bridge infill": 86.61878955677516, + "Custom": 1.1925285421682654, + "External perimeter": 727.5477605614387, + "Gap fill": 11.412536370727818, + "Internal infill": 1035.7116673043204, + "Overhang perimeter": 0.966786878164481, + "Perimeter": 562.2619470028691, + "Skirt/Brim": 22.540480459569807, + "Solid infill": 573.0419719937473, + "Top solid infill": 65.26868885629558 + }, + "layer_times": [ + [ + 0, + 0.05059644256269407 + ], + [ + 0.2, + 58.177320509927746 + ], + [ + 0.5, + 31.954084022391182 + ], + [ + 0.6, + 0.9089208501630478 + ], + [ + 0.8, + 33.99706357305071 + ], + [ + 1.1, + 25.96804446085199 + ], + [ + 1.4, + 26.479048320454805 + ], + [ + 1.7, + 26.582581091690333 + ], + [ + 2, + 27.072868276853875 + ], + [ + 2.3, + 23.266380178000148 + ], + [ + 2.6, + 23.32793916103499 + ], + [ + 2.9, + 22.68151682077201 + ], + [ + 3.2, + 39.49402504999236 + ], + [ + 3.5, + 27.195385252006332 + ], + [ + 3.8, + 28.109438088654816 + ], + [ + 4.1, + 24.08349852277251 + ], + [ + 4.4, + 23.917674876902552 + ], + [ + 4.7, + 23.202091559017017 + ], + [ + 5, + 23.198343943562456 + ], + [ + 5.3, + 22.153783595351126 + ], + [ + 5.6, + 21.808169596228392 + ], + [ + 5.9, + 21.904068197159418 + ], + [ + 6.2, + 21.726213600349016 + ], + [ + 6.5, + 21.555689782559813 + ], + [ + 6.8, + 21.56001088763045 + ], + [ + 7.1, + 21.616583527557026 + ], + [ + 7.4, + 21.587509695967398 + ], + [ + 7.7, + 21.582874923811257 + ], + [ + 8, + 21.57728927279966 + ], + [ + 8.3, + 21.76624101342738 + ], + [ + 8.6, + 21.450965502680578 + ], + [ + 8.9, + 21.461465610564524 + ], + [ + 9.2, + 21.366725852519636 + ], + [ + 9.5, + 21.362167027170038 + ], + [ + 9.8, + 25.600580479722474 + ], + [ + 10.1, + 26.282946643536636 + ], + [ + 10.4, + 26.693162061300253 + ], + [ + 10.7, + 25.87730466751283 + ], + [ + 11, + 25.837521272340645 + ], + [ + 11.3, + 25.220649143903664 + ], + [ + 11.6, + 24.91627368335564 + ], + [ + 11.9, + 24.565979527961527 + ], + [ + 12.2, + 21.901257609622963 + ], + [ + 12.5, + 21.26785043389243 + ], + [ + 12.8, + 21.099317506268335 + ], + [ + 13.1, + 21.524648538390988 + ], + [ + 13.4, + 24.108699996006557 + ], + [ + 13.7, + 24.373866962973825 + ], + [ + 14, + 25.230795272831255 + ], + [ + 14.3, + 25.47226683972438 + ], + [ + 14.6, + 26.051098821629687 + ], + [ + 14.9, + 26.2540071554197 + ], + [ + 15.2, + 26.54261709911606 + ], + [ + 15.5, + 22.769433528123376 + ], + [ + 15.8, + 22.57337903594234 + ], + [ + 16.1, + 22.120135631848644 + ], + [ + 16.4, + 22.302142435605443 + ], + [ + 16.7, + 22.490758568112852 + ], + [ + 17, + 22.216297455855806 + ], + [ + 17.3, + 22.241988841558136 + ], + [ + 17.6, + 22.030502249189826 + ], + [ + 17.9, + 21.442566629762368 + ], + [ + 18.2, + 21.537227968334165 + ], + [ + 18.5, + 21.187671992912446 + ], + [ + 18.8, + 21.176477375060422 + ], + [ + 19.1, + 21.176107665494644 + ], + [ + 19.4, + 21.164450306340775 + ], + [ + 19.7, + 21.211793185762044 + ], + [ + 20, + 21.049079879215107 + ], + [ + 20.3, + 21.018544238429598 + ], + [ + 20.6, + 20.833976711167224 + ], + [ + 20.9, + 20.833976711167224 + ], + [ + 21.2, + 20.833976711167224 + ], + [ + 21.5, + 20.833976711167224 + ], + [ + 21.8, + 20.833976711167224 + ], + [ + 22.1, + 20.833976711167224 + ], + [ + 22.4, + 21.258875428281975 + ], + [ + 22.7, + 21.303045487271195 + ], + [ + 23, + 21.54997891912768 + ], + [ + 23.3, + 21.4000724519804 + ], + [ + 23.6, + 21.172838007877022 + ], + [ + 23.9, + 21.89326824952405 + ], + [ + 24.2, + 22.260210513833638 + ], + [ + 24.5, + 22.34815676766725 + ], + [ + 24.8, + 23.018360476759195 + ], + [ + 25.1, + 22.83742910264808 + ], + [ + 25.4, + 21.884928399224517 + ], + [ + 25.7, + 21.16791844379882 + ], + [ + 26, + 21.062339082163817 + ], + [ + 26.3, + 20.497926920922225 + ], + [ + 26.6, + 20.441458670088437 + ], + [ + 26.9, + 20.497926920922225 + ], + [ + 27.2, + 21.411213211524064 + ], + [ + 27.5, + 21.205564835097203 + ], + [ + 27.8, + 21.403735651236662 + ], + [ + 28.1, + 21.72317504502876 + ], + [ + 28.4, + 20.83804429637327 + ], + [ + 28.7, + 20.992445860036398 + ], + [ + 29, + 20.96056166031732 + ], + [ + 29.3, + 20.96056166031732 + ], + [ + 29.6, + 20.96056166031732 + ], + [ + 29.9, + 20.96056166031732 + ], + [ + 30.2, + 21.163385361246583 + ], + [ + 30.5, + 21.375398470771565 + ], + [ + 30.8, + 21.845443716854845 + ], + [ + 31.1, + 21.003381151310677 + ], + [ + 31.4, + 20.660669538703793 + ], + [ + 31.7, + 20.497926920922225 + ], + [ + 32, + 20.441458670088437 + ], + [ + 32.3, + 20.497926920922225 + ], + [ + 32.6, + 20.441458670088437 + ], + [ + 32.9, + 20.497926920922225 + ], + [ + 33.2, + 20.441458670088437 + ], + [ + 33.5, + 20.497926920922225 + ], + [ + 33.8, + 20.441458670088437 + ], + [ + 34.1, + 36.85516926371657 + ], + [ + 34.4, + 23.906291084020573 + ], + [ + 34.7, + 24.10243730191063 + ], + [ + 35, + 29.058094876089566 + ], + [ + 35.3, + 21.585307365265763 + ], + [ + 35.6, + 21.977729818546266 + ], + [ + 35.9, + 21.982243563755652 + ], + [ + 36.2, + 21.84660060776076 + ], + [ + 36.5, + 21.852866392888306 + ], + [ + 36.8, + 21.809194828486756 + ], + [ + 37.1, + 20.510222555418448 + ], + [ + 37.4, + 19.19335211292996 + ], + [ + 37.7, + 17.170142031218244 + ], + [ + 38, + 15.027435648219916 + ], + [ + 38.3, + 12.070425871333898 + ], + [ + 38.6, + 9.187916276700111 + ], + [ + 38.9, + 8.965728773112703 + ], + [ + 39.2, + 6.353229978247989 + ], + [ + 39.5, + 6.225660195566472 + ], + [ + 39.8, + 0.5801244322591914 + ], + [ + 40.1, + 0.2925785856185972 + ] + ] +} +``` +/// + +/// api-response-spec + open: True + +//// Note +This specification applies to the values returned by +Klipper Estimator version `v3.7.3`. + +All time estimates are reported in seconds. +//// + +| Field | Type | Description | +| ------------------------- | :-------: | ------------------------------------------------------ | +| `total_time` | float | The total estimated time spent on the job. | +| `total_distance` | float | The total estimated travel distance of the tool in mm. | +| `total_extrude_distance` | float | The total estimated extrude distance in mm. | +| `max_flow` | float | The maximum flow rate detected in mm^3^/s. | +| `max_speed` | float | The maximum tool movement speed detected in mm/s. | +| `num_moves` | int | The total number of moves detected. | +| `total_z_time` | float | The estimated amount of time spent moving on the | +| | | Z axis. |^ +| `total_output_time` | float | The estimated amount of time moving while extruding. | +| `total_travel_time` | float | The estimated amount of time the tool spent traveling. | +| `total_extrude_only_time` | float | The estimated amount of time the tool spent extruding | +| | | without other movement. |^ +| `phase_times` | object | A `Phase Times` object. | +| | | #phase-times-object-spec |+ +| `kind_times` | object | A `Kind Times` object. | +| | | #kind-times-object-spec |+ +| `layer_times` | [[float]] | An array of 2-element arrays. The first element | +| | | is the layer height, the second is the estimated |^ +| | | time spent printing the layer. |^ + +| Field | Type | Description | +| -------------- | :---: | ----------------------------------------------------- | +| `acceleration` | float | The amount of time the tool spent accelerating during | +| | | the print job. |^ +| `cruise` | float | The amount of time the tool spent at cruise velocity | +| | | during the print job. |^ +| `deceleration` | float | The amount of time the tool spent decelerating during | +| | | the print job. |^ +{ #phase-times-object-spec } Phase Times + +| Field | Type | Description | +| ----------- | :---: | ----------------------------------------------------------------- | +| *kind_desc* | float | An entry where the key is a description of the "kind" of item | +| | | being printed and its value is the total time spent printing |^ +| | | this "kind". The "kind" is determined by comments in the slicer. |^ +| | | For example `Perimeter` and `Bridge infill` are "kinds" reported |^ +| | | by PrusaSlicer. If the "kind" is not available Klipper Estimator |^ +| | | will report it under `Other`. The `Kind Times` object may have |^ +| | | multiple *kind_desc* entries. |^ +{ #kind-times-object-spec } Kind Times + +/// + + +### Dump the current configuration + +Create a Klipper Estimator configuration file using Klippy's +current settings. + +/// note +Klippy must be connected and in the `ready` state to run +this request. +/// + +```{.http .apirequest title="HTTP Request"} +POST /server/analysis/dump_config +Content-Type: application/json + +{ + "dest_config": "custom_estimator_cfg.json" +} +``` + +```{.json .apirequest title="JSON-RPC Request"} +{ + "jsonrpc": "2.0", + "method": "server.analysis.dump_config", + "params": { + "dest_config": "custom_estimator_cfg.json" + } + "id": 4654 +} +``` + +/// api-parameters + open: True + +| Name | Type | Default | Description | +| ------------- | :----: | ------- | ----------------------------------------- | +| `dest_config` | string | null | The name of the destination config file | +| | | | for the dump. This should be a path |^ +| | | | relative to the `config` root folder. |^ +| | | | If omitted the result of the dump will |^ +| | | | be saved to the default Klipper Estimator |^ +| | | | configuration file. |^ + +//// Note +The default configuration for Klipper Estimator is stored in the same +folder as the binary. + +``` +/tools/klipper_estimator/default_estimator_cfg.json +``` +//// + +/// + +/// collapse-code +```{.json .apiresponse title="Example Response"} +{ + "dest_root": "config", + "dest_config_path": "est_cfg_test.json", + "klipper_estimator_config": { + "max_velocity": 300, + "max_acceleration": 1500, + "minimum_cruise_ratio": 0.5, + "square_corner_velocity": 5, + "instant_corner_velocity": 1, + "move_checkers": [ + { + "axis_limiter": { + "axis": [ + 0, + 0, + 1 + ], + "max_velocity": 15, + "max_accel": 200 + } + }, + { + "extruder_limiter": { + "max_velocity": 120, + "max_accel": 1250 + } + } + ] + } +} +``` +/// + +/// api-response-spec + open: True + +| Field | Type | Description | +| -------------------------- | :------------: | ----------------------------------------- | +| `dest_root` | string \| null | The destination root folder of the dumped | +| | | configuration file. Will be `null` if |^ +| | | the dumped file is the default config. |^ +| `dest_config` | sting | The path of the dumped configuration file | +| | | relative to the `dest_root`. If the |^ +| | | `dest_root` is null then this will be |^ +| | | the default configuration's file name. |^ +| `klipper_estimator_config` | object | An object containing the output of the | +| | | dump command. |^ + +/// + ## OctoPrint API emulation From 706455a6f80738a3a75daee533791bb00e59cd34 Mon Sep 17 00:00:00 2001 From: Eric Callahan Date: Tue, 11 Feb 2025 16:36:36 -0500 Subject: [PATCH 11/12] source_info: include git worktree detection Standard git repos contain a ".git" folder, however worktrees contain a ".git" file. The file provides the path to the worktree's git directory. Signed-off-by: Eric Callahan --- moonraker/utils/source_info.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/moonraker/utils/source_info.py b/moonraker/utils/source_info.py index a9c75a3bd..512734cca 100644 --- a/moonraker/utils/source_info.py +++ b/moonraker/utils/source_info.py @@ -32,15 +32,15 @@ def source_path() -> pathlib.Path: def is_git_repo(src_path: Optional[pathlib.Path] = None) -> bool: if src_path is None: src_path = source_path() - return src_path.joinpath(".git").is_dir() + return src_path.joinpath(".git").exists() def find_git_repo(src_path: Optional[pathlib.Path] = None) -> Optional[pathlib.Path]: if src_path is None: src_path = source_path() - if src_path.joinpath(".git").is_dir(): + if src_path.joinpath(".git").exists(): return src_path for parent in src_path.parents: - if parent.joinpath(".git").is_dir(): + if parent.joinpath(".git").exists(): return parent return None From 354cc11b8e3cd3c1353b9f961d768b3bb615060d Mon Sep 17 00:00:00 2001 From: Eric Callahan Date: Wed, 12 Feb 2025 06:28:53 -0500 Subject: [PATCH 12/12] docs: fix typos Signed-off-by: Eric Callahan --- docs/configuration.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 52594c42b..670fbd590 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -379,8 +379,6 @@ service: mjpegstreamer # ends may use this configuration to determine how to connect to the service # and interpret its stream. See the tip following this example for # currently known values. The default is "mjpegstreamer". -location: printer -# A string describing the location of the camera. Default is printer. target_fps: 15 # An integer value specifying the target framerate. The default is 15 fps. target_fps_idle: 5 @@ -545,7 +543,7 @@ auto_dump_default_config: false # configuration is only dumped if the default configuration file # does not exist. The default is false. estimator_timeout: 600 -# The maximum amount of time (in seconds) Klipper Esti+mator +# The maximum amount of time (in seconds) Klipper Estimator # is given to process a gcode file before processing is # aborted. The default is 600 seconds. enable_auto_analysis: false @@ -1938,7 +1936,7 @@ of Moonraker and/or Klipper. #### The release_info.json file -The `web`, `zip`, and `exectuable` types require that the install +The `web`, `zip`, and `executable` types require that the install folder contain a `release_info.json` file. This file contains information the update manager uses to validate the local install. @@ -2241,9 +2239,9 @@ the folder containing the executable. The `executable` type can be used to deploy pre-built executable binaries through GitHub releases. Executable types can be installed as system services -and may specifiy OS package dependencies. +and may specify OS package dependencies. -Like `web` and `zip` types, `executuable` types must include a `release_info.json` +Like `web` and `zip` types, `executable` types must include a `release_info.json` file (see the [web type](#web-type-front-end-configuration) not for details). In addition, `executable` types can be configured to update dependencies and manage services.