analysis: initial implementation

Adds support for GCode file time analysis using Klipper Estimator.

Signed-off-by:  Eric Callahan <arksine.code@gmail.com>
This commit is contained in:
Eric Callahan 2023-06-28 06:30:06 -04:00
parent 3ec968d873
commit f2c564cfb7

View File

@ -0,0 +1,448 @@
# Printer GCode Analysis using Klipper Estimator
#
# Copyright (C) 2025 Eric Callahan <arksine.code@gmail.com>
#
# 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)