moonraker/moonraker/components/simplyprint.py

1677 lines
66 KiB
Python

# SimplyPrint Connection Support
#
# Copyright (C) 2022 Eric Callahan <arksine.code@gmail.com>
#
# This file may be distributed under the terms of the GNU GPLv3 license.
from __future__ import annotations
import os
import asyncio
import json
import logging
import time
import pathlib
import base64
import tornado.websocket
from tornado.escape import url_escape
import logging.handlers
import tempfile
from queue import SimpleQueue
from ..loghelper import LocalQueueHandler
from ..common import Subscribable, WebRequest
from typing import (
TYPE_CHECKING,
Awaitable,
Optional,
Dict,
List,
Union,
Any,
)
if TYPE_CHECKING:
from ..app import InternalTransport
from ..confighelper import ConfigHelper
from ..websockets import WebsocketManager
from ..common import BaseRemoteConnection
from tornado.websocket import WebSocketClientConnection
from .database import MoonrakerDatabase
from .klippy_apis import KlippyAPI
from .job_state import JobState
from .machine import Machine
from .file_manager.file_manager import FileManager
from .http_client import HttpClient
from .power import PrinterPower
from .announcements import Announcements
from .webcam import WebcamManager, WebCam
from ..klippy_connection import KlippyConnection
COMPONENT_VERSION = "0.0.1"
SP_VERSION = "0.1"
TEST_ENDPOINT = f"wss://testws.simplyprint.io/{SP_VERSION}/p"
PROD_ENDPOINT = f"wss://ws.simplyprint.io/{SP_VERSION}/p"
# TODO: Increase this time to something greater, perhaps 30 minutes
CONNECTION_ERROR_LOG_TIME = 60.
PRE_SETUP_EVENTS = [
"connection", "state_change", "shutdown", "machine_data", "firmware",
"ping"
]
class SimplyPrint(Subscribable):
def __init__(self, config: ConfigHelper) -> None:
self.server = config.get_server()
self._logger = ProtoLogger(config)
self.eventloop = self.server.get_event_loop()
self.job_state: JobState
self.job_state = self.server.lookup_component("job_state")
self.klippy_apis: KlippyAPI
self.klippy_apis = self.server.lookup_component("klippy_apis")
database: MoonrakerDatabase = self.server.lookup_component("database")
database.register_local_namespace("simplyprint", forbidden=True)
self.spdb = database.wrap_namespace("simplyprint")
self.sp_info = self.spdb.as_dict()
self.is_closing = False
self.ws: Optional[WebSocketClientConnection] = None
self.cache = ReportCache()
ambient = self.sp_info.get("ambient_temp", INITIAL_AMBIENT)
self.amb_detect = AmbientDetect(config, self, ambient)
self.layer_detect = LayerDetect()
self.webcam_stream = WebcamStream(config, self)
self.print_handler = PrintHandler(self)
self.last_received_temps: Dict[str, float] = {}
self.last_err_log_time: float = 0.
self.last_cpu_update_time: float = 0.
self.intervals: Dict[str, float] = {
"job": 1.,
"temps": 1.,
"temps_target": .25,
"cpu": 10.,
"ai": 0.,
"ping": 20.,
}
self.printer_status: Dict[str, Dict[str, Any]] = {}
self.heaters: Dict[str, str] = {}
self.missed_job_events: List[Dict[str, Any]] = []
self.announce_mutex = asyncio.Lock()
self.connection_task: Optional[asyncio.Task] = None
self.reconnect_delay: float = 1.
self.reconnect_token: Optional[str] = None
self._last_sp_ping: float = 0.
self.ping_sp_timer = self.eventloop.register_timer(self._handle_sp_ping)
self.printer_info_timer = self.eventloop.register_timer(
self._handle_printer_info_update)
self._print_request_event: asyncio.Event = asyncio.Event()
self.next_temp_update_time: float = 0.
self._last_ping_received: float = 0.
self.gcode_terminal_enabled: bool = False
self.connected = False
self.is_set_up = False
self.test = config.get("use_test_endpoint", False)
connect_url = config.get("url", None)
if connect_url is not None:
self.connect_url = connect_url
self.is_set_up = True
else:
self._set_ws_url()
self.power_id: str = ""
power_id: Optional[str] = config.get("power_device", None)
if power_id is not None:
self.power_id = power_id
if self.power_id.startswith("power "):
self.power_id = self.power_id[6:]
if not config.has_section(f"power {self.power_id}"):
self.power_id = ""
self.server.add_warning(
"Section [simplyprint], option 'power_device': Unable "
f"to locate configuration for power device {power_id}"
)
else:
power_pfx = config.get_prefix_sections("power ")
if len(power_pfx) == 1:
name = power_pfx[0][6:]
if "printer" in name.lower():
self.power_id = name
self.filament_sensor: str = ""
fsensor = config.get("filament_sensor", None)
fs_prefixes = ["filament_switch_sensor ", "filament_motion_sensor "]
if fsensor is not None:
for prefix in fs_prefixes:
if fsensor.startswith(prefix):
self.filament_sensor = fsensor
break
else:
self.server.add_warning(
"Section [simplyprint], option 'filament_sensor': Invalid "
f"sensor '{fsensor}', must start with one the following "
f"prefixes: {fs_prefixes}"
)
# Register State Events
self.server.register_event_handler(
"server:klippy_started", self._on_klippy_startup)
self.server.register_event_handler(
"server:klippy_ready", self._on_klippy_ready)
self.server.register_event_handler(
"server:klippy_shutdown", self._on_klippy_shutdown)
self.server.register_event_handler(
"server:klippy_disconnect", self._on_klippy_disconnected)
self.server.register_event_handler(
"job_state:started", self._on_print_start)
self.server.register_event_handler(
"job_state:paused", self._on_print_paused)
self.server.register_event_handler(
"job_state:resumed", self._on_print_resumed)
self.server.register_event_handler(
"job_state:standby", self._on_print_standby)
self.server.register_event_handler(
"job_state:complete", self._on_print_complete)
self.server.register_event_handler(
"job_state:error", self._on_print_error)
self.server.register_event_handler(
"job_state:cancelled", self._on_print_cancelled)
self.server.register_event_handler(
"klippy_apis:pause_requested", self._on_pause_requested)
self.server.register_event_handler(
"klippy_apis:resume_requested", self._on_resume_requested)
self.server.register_event_handler(
"klippy_apis:cancel_requested", self._on_cancel_requested)
self.server.register_event_handler(
"proc_stats:proc_stat_update", self._on_proc_update)
self.server.register_event_handler(
"proc_stats:cpu_throttled", self._on_cpu_throttled
)
self.server.register_event_handler(
"websockets:client_identified", self._on_websocket_identified)
self.server.register_event_handler(
"websockets:client_removed", self._on_websocket_removed)
self.server.register_event_handler(
"server:gcode_response", self._on_gcode_response)
self.server.register_event_handler(
"klippy_connection:gcode_received", self._on_gcode_received
)
self.server.register_event_handler(
"power:power_changed", self._on_power_changed
)
async def component_init(self) -> None:
await self.webcam_stream.intialize_url()
await self.webcam_stream.test_connection()
self.connection_task = self.eventloop.create_task(self._connect())
async def _connect(self) -> None:
log_connect = True
while not self.is_closing:
url = self.connect_url
if self.reconnect_token is not None:
url = f"{self.connect_url}/{self.reconnect_token}"
if log_connect:
logging.info(f"Connecting To SimplyPrint: {url}")
log_connect = False
try:
self.ws = await tornado.websocket.websocket_connect(
url, connect_timeout=5.,
)
setattr(self.ws, "on_ping", self._on_ws_ping)
cur_time = self.eventloop.get_loop_time()
self._last_ping_received = cur_time
except asyncio.CancelledError:
raise
except Exception:
curtime = self.eventloop.get_loop_time()
timediff = curtime - self.last_err_log_time
if timediff > CONNECTION_ERROR_LOG_TIME:
self.last_err_log_time = curtime
logging.exception(
f"Failed to connect to SimplyPrint")
else:
logging.info("Connected to SimplyPrint Cloud")
await self._read_messages()
log_connect = True
if not self.is_closing:
await asyncio.sleep(self.reconnect_delay)
async def _read_messages(self) -> None:
message: Union[str, bytes, None]
while self.ws is not None:
message = await self.ws.read_message()
if isinstance(message, str):
self._process_message(message)
elif message is None:
self.ping_sp_timer.stop()
cur_time = self.eventloop.get_loop_time()
ping_time: float = cur_time - self._last_ping_received
reason = code = None
if self.ws is not None:
reason = self.ws.close_reason
code = self.ws.close_code
msg = (
f"SimplyPrint Disconnected - Code: {code}, "
f"Reason: {reason}, "
f"Server Ping Time Elapsed: {ping_time}"
)
logging.info(msg)
self.connected = False
self.ws = None
break
def _on_ws_ping(self, data: bytes = b"") -> None:
self._last_ping_received = self.eventloop.get_loop_time()
def _process_message(self, msg: str) -> None:
self._logger.info(f"received: {msg}")
try:
packet: Dict[str, Any] = json.loads(msg)
except json.JSONDecodeError:
logging.debug(f"Invalid message, not JSON: {msg}")
return
event: str = packet.get("type", "")
data: Optional[Dict[str, Any]] = packet.get("data")
if event == "connected":
logging.info("SimplyPrint Reports Connection Success")
self.connected = True
self.reconnect_token = None
if data is not None:
if data.get("in_setup", 0) == 1:
self.is_set_up = False
self.save_item("printer_id", None)
self.save_item("printer_name", None)
if "short_id" in data:
self.eventloop.create_task(
self._announce_setup(data["short_id"])
)
interval = data.get("interval")
if isinstance(interval, dict):
self._update_intervals(interval)
self.reconnect_token = data.get("reconnect_token")
name = data.get("name")
if name is not None:
self.save_item("printer_name", name)
self.reconnect_delay = 1.
self._push_initial_state()
self.ping_sp_timer.start()
elif event == "error":
logging.info(f"SimplyPrint Connection Error: {data}")
self.reconnect_delay = 30.
self.reconnect_token = None
elif event == "new_token":
if data is None:
logging.debug("Invalid message, no data")
return
if data.get("no_exist", False) is True and self.is_set_up:
self.is_set_up = False
self.save_item("printer_id", None)
token: Optional[str] = data.get("token")
if not isinstance(token, str):
logging.debug(f"Invalid token received: {token}")
token = None
else:
logging.info(f"SimplyPrint Token Received")
self.save_item("printer_token", token)
self._set_ws_url()
if "short_id" in data:
short_id = data["short_id"]
if not isinstance(short_id, str):
self._logger.debug(f"Invalid short_id received: {short_id}")
else:
self.eventloop.create_task(
self._announce_setup(data["short_id"])
)
elif event == "complete_setup":
if data is None:
logging.debug("Invalid message, no data")
return
printer_id = data.get("printer_id")
if printer_id is None:
logging.debug(f"Invalid printer id, received null (None) value")
self.save_item("printer_id", str(printer_id))
self._set_ws_url()
self.save_item("temp_short_setup_id", None)
self.eventloop.create_task(self._remove_setup_announcement())
elif event == "demand":
if data is None:
logging.debug(f"Invalid message, no data")
return
demand = data.pop("demand", "unknown")
self._process_demand(demand, data)
elif event == "interval_change":
if isinstance(data, dict):
self._update_intervals(data)
elif event == "pong":
diff = self.eventloop.get_loop_time() - self._last_sp_ping
self.send_sp("latency", {"ms": int(diff * 1000 + .5)})
else:
# TODO: It would be good for the backend to send an
# event indicating that it is ready to recieve printer
# status.
logging.debug(f"Unknown event: {msg}")
def _process_demand(self, demand: str, args: Dict[str, Any]) -> None:
kconn: KlippyConnection
kconn = self.server.lookup_component("klippy_connection")
if demand in ["pause", "resume", "cancel"]:
if not kconn.is_connected():
return
self.eventloop.create_task(self._request_print_action(demand))
elif demand == "terminal":
if "enabled" in args:
self.gcode_terminal_enabled = args["enabled"]
elif demand == "gcode":
if not kconn.is_connected():
return
script_list = args.get("list", [])
if script_list:
script = "\n".join(script_list)
coro = self.klippy_apis.run_gcode(script, None)
self.eventloop.create_task(coro)
elif demand == "webcam_snapshot":
self.eventloop.create_task(self.webcam_stream.post_image(args))
elif demand == "file":
url: Optional[str] = args.get("url")
if not isinstance(url, str):
logging.debug(f"Invalid url in message")
return
start = bool(args.get("auto_start", 0))
self.print_handler.download_file(url, start)
elif demand == "start_print":
if (
kconn.is_connected() and
self.cache.state == "operational"
):
self.eventloop.create_task(self.print_handler.start_print())
else:
logging.debug("Failed to start print")
elif demand == "system_restart":
coro = self._call_internal_api("machine.reboot")
self.eventloop.create_task(coro)
elif demand == "system_shutdown":
coro = self._call_internal_api("machine.shutdown")
self.eventloop.create_task(coro)
elif demand == "api_restart":
self.eventloop.create_task(self._do_service_action("restart"))
elif demand == "api_shutdown":
self.eventloop.create_task(self._do_service_action("shutdown"))
elif demand == "psu_on":
self._do_power_action("on")
elif demand == "psu_off":
self._do_power_action("off")
elif demand == "test_webcam":
self.eventloop.create_task(self._test_webcam())
else:
logging.debug(f"Unknown demand: {demand}")
def save_item(self, name: str, data: Any):
if data is None:
self.sp_info.pop(name, None)
self.spdb.pop(name, None)
else:
self.sp_info[name] = data
self.spdb[name] = data
async def _call_internal_api(self, method: str, **kwargs) -> Any:
itransport: InternalTransport
itransport = self.server.lookup_component("internal_transport")
try:
ret = await itransport.call_method(method, **kwargs)
except self.server.error:
return None
return ret
def _set_ws_url(self) -> None:
token: Optional[str] = self.sp_info.get("printer_token")
printer_id: Optional[str] = self.sp_info.get("printer_id")
ep = TEST_ENDPOINT if self.test else PROD_ENDPOINT
self.connect_url = f"{ep}/0/0"
if token is not None:
if printer_id is None:
self.connect_url = f"{ep}/0/{token}"
else:
self.is_set_up = True
self.connect_url = f"{ep}/{printer_id}/{token}"
def _update_intervals(self, intervals: Dict[str, Any]) -> None:
for key, val in intervals.items():
self.intervals[key] = val / 1000.
cur_ai_interval = self.intervals.get("ai", 0.)
if not cur_ai_interval:
self.webcam_stream.stop_ai()
logging.debug(f"Intervals Updated: {self.intervals}")
async def _announce_setup(self, short_id: str) -> None:
async with self.announce_mutex:
eid: Optional[str] = self.sp_info.get("announcement_id")
if (
eid is not None and
self.sp_info.get("temp_short_setup_id") == short_id
):
return
ann: Announcements = self.server.lookup_component("announcements")
if eid is not None:
# remove stale announcement
try:
await ann.remove_announcement(eid)
except self.server.error:
pass
self.save_item("temp_short_setup_id", short_id)
entry = ann.add_internal_announcement(
"SimplyPrint Setup Request",
"SimplyPrint is ready to complete setup for your printer. "
"Please log in to your account and enter the following "
f"setup code:\n\n{short_id}\n\n",
"https://simplyprint.io", "high", "simplyprint"
)
eid = entry.get("entry_id")
self.save_item("announcement_id", eid)
async def _remove_setup_announcement(self) -> None:
async with self.announce_mutex:
eid = self.sp_info.get("announcement_id")
if eid is None:
return
self.save_item("announcement_id", None)
ann: Announcements = self.server.lookup_component("announcements")
try:
await ann.remove_announcement(eid)
except self.server.error:
pass
def _do_power_action(self, state: str) -> None:
if self.power_id:
power: PrinterPower = self.server.lookup_component("power")
power.set_device_power(self.power_id, state)
async def _do_service_action(self, action: str) -> None:
try:
machine: Machine = self.server.lookup_component("machine")
await machine.do_service_action(action, "moonraker")
except self.server.error:
pass
async def _request_print_action(self, action: str) -> None:
cur_state = self.cache.state
ret: Optional[str] = ""
self._print_request_event.clear()
if action == "pause":
if cur_state == "printing":
self._update_state("pausing")
ret = await self.klippy_apis.pause_print(None)
elif action == "resume":
if cur_state == "paused":
self._print_request_fut = self.eventloop.create_future()
self._update_state("resuming")
ret = await self.klippy_apis.resume_print(None)
elif action == "cancel":
if cur_state in ["printing", "paused"]:
self._update_state("cancelling")
ret = await self.klippy_apis.cancel_print(None)
if ret is None:
# Wait for the "action" requested event to fire, then reset the
# state
try:
await asyncio.wait_for(self._print_request_event.wait(), 1.)
except Exception:
pass
self._update_state_from_klippy()
async def _test_webcam(self) -> None:
await self.webcam_stream.test_connection()
self.send_sp(
"webcam_status", {"connected": self.webcam_stream.connected}
)
async def _on_klippy_ready(self) -> None:
last_stats: Dict[str, Any] = self.job_state.get_last_stats()
if last_stats["state"] == "printing":
self._on_print_start(last_stats, last_stats, False)
else:
self._update_state("operational")
query: Optional[Dict[str, Any]]
query = await self.klippy_apis.query_objects({"heaters": None}, None)
sub_objs = {
"display_status": ["progress"],
"bed_mesh": ["mesh_matrix", "mesh_min", "mesh_max"],
"toolhead": ["extruder"],
"gcode_move": ["gcode_position"]
}
# Add Heater Subscriptions
has_amb_sensor: bool = False
cfg_amb_sensor = self.amb_detect.sensor_name
if query is not None:
heaters: Dict[str, Any] = query.get("heaters", {})
avail_htrs: List[str]
avail_htrs = sorted(heaters.get("available_heaters", []))
logging.debug(f"SimplyPrint: Heaters Detected: {avail_htrs}")
for htr in avail_htrs:
if htr.startswith("extruder"):
sub_objs[htr] = ["temperature", "target"]
if htr == "extruder":
tool_id = "tool0"
else:
tool_id = "tool" + htr[8:]
self.heaters[htr] = tool_id
elif htr == "heater_bed":
sub_objs[htr] = ["temperature", "target"]
self.heaters[htr] = "bed"
sensors: List[str] = heaters.get("available_sensors", [])
if cfg_amb_sensor:
if cfg_amb_sensor in sensors:
has_amb_sensor = True
sub_objs[cfg_amb_sensor] = ["temperature"]
else:
logging.info(
f"SimplyPrint: Ambient sensor {cfg_amb_sensor} not "
"configured in Klipper"
)
if self.filament_sensor:
objects: List[str]
objects = await self.klippy_apis.get_object_list(default=[])
if self.filament_sensor in objects:
sub_objs[self.filament_sensor] = ["filament_detected"]
# Add filament sensor subscription
if not sub_objs:
return
# Create our own subscription rather than use the host sub
args = {'objects': sub_objs}
klippy: KlippyConnection
klippy = self.server.lookup_component("klippy_connection")
try:
resp: Dict[str, Dict[str, Any]] = await klippy.request(
WebRequest("objects/subscribe", args, conn=self))
status: Dict[str, Any] = resp.get("status", {})
except self.server.error:
status = {}
if status:
logging.debug(f"SimplyPrint: Got Initial Status: {status}")
self.printer_status = status
self._update_temps(1.)
self.next_temp_update_time = 0.
if "bed_mesh" in status:
self._send_mesh_data()
if "toolhead" in status:
self._send_active_extruder(status["toolhead"]["extruder"])
if "gcode_move" in status:
self.layer_detect.update(
status["gcode_move"]["gcode_position"]
)
if self.filament_sensor and self.filament_sensor in status:
detected = status[self.filament_sensor]["filament_detected"]
fstate = "loaded" if detected else "runout"
self.cache.filament_state = fstate
self.send_sp("filament_sensor", {"state": fstate})
if has_amb_sensor and cfg_amb_sensor in status:
self.amb_detect.update_ambient(status[cfg_amb_sensor])
if not has_amb_sensor:
self.amb_detect.start()
self.printer_info_timer.start(delay=1.)
def _on_power_changed(self, device_info: Dict[str, Any]) -> None:
if self.power_id and device_info["device"] == self.power_id:
is_on = device_info["status"] == "on"
self.send_sp("power_controller", {"on": is_on})
def _on_websocket_identified(self, ws: BaseRemoteConnection) -> None:
if (
self.cache.current_wsid is None and
ws.client_data.get("type", "") == "web"
):
ui_data: Dict[str, Any] = {
"ui": ws.client_data["name"],
"ui_version": ws.client_data["version"]
}
self.cache.firmware_info.update(ui_data)
self.cache.current_wsid = ws.uid
self.send_sp("machine_data", ui_data)
def _on_websocket_removed(self, ws: BaseRemoteConnection) -> None:
if self.cache.current_wsid is None or self.cache.current_wsid != ws.uid:
return
ui_data = self._get_ui_info()
diff = self._get_object_diff(ui_data, self.cache.firmware_info)
if diff:
self.cache.firmware_info.update(ui_data)
self.send_sp("machine_data", ui_data)
def _on_klippy_startup(self, state: str) -> None:
if state != "ready":
self._update_state("error")
kconn: KlippyConnection
kconn = self.server.lookup_component("klippy_connection")
self.send_sp("printer_error", {"error": kconn.state_message})
self.send_sp("connection", {"new": "connected"})
self._send_firmware_data()
def _on_klippy_shutdown(self) -> None:
self._update_state("error")
kconn: KlippyConnection
kconn = self.server.lookup_component("klippy_connection")
self.send_sp("printer_error", {"error": kconn.state_message})
def _on_klippy_disconnected(self) -> None:
self._update_state("offline")
self.send_sp("connection", {"new": "disconnected"})
self.amb_detect.stop()
self.printer_info_timer.stop()
self.cache.reset_print_state()
self.printer_status = {}
def _on_print_start(
self,
prev_stats: Dict[str, Any],
new_stats: Dict[str, Any],
need_start_event: bool = True
) -> None:
# inlcludes started and resumed events
self._update_state("printing")
filename = new_stats["filename"]
job_info: Dict[str, Any] = {"filename": filename}
fm: FileManager = self.server.lookup_component("file_manager")
metadata = fm.get_file_metadata(filename)
filament: Optional[float] = metadata.get("filament_total")
if filament is not None:
job_info["filament"] = round(filament)
est_time = metadata.get("estimated_time")
if est_time is not None:
job_info["time"] = est_time
self.cache.metadata = metadata
self.cache.job_info.update(job_info)
if need_start_event:
job_info["started"] = True
self.layer_detect.start(metadata)
self._send_job_event(job_info)
self.webcam_stream.reset_ai_scores()
self.webcam_stream.start_ai(120.)
def _check_job_started(
self,
prev_stats: Dict[str, Any],
new_stats: Dict[str, Any]
) -> None:
if not self.cache.job_info:
job_info: Dict[str, Any] = {
"filename": new_stats.get("filename", ""),
"started": True
}
self._send_job_event(job_info)
def _reset_file(self) -> None:
cur_job = self.cache.job_info.get("filename", "")
last_started = self.print_handler.last_started
if last_started and last_started == cur_job:
kapi: KlippyAPI = self.server.lookup_component("klippy_apis")
self.eventloop.create_task(
kapi.run_gcode("SDCARD_RESET_FILE", default=None)
)
self.print_handler.last_started = ""
def _on_print_paused(self, *args) -> None:
self.send_sp("job_info", {"paused": True})
self._update_state("paused")
self.layer_detect.stop()
self.webcam_stream.stop_ai()
def _on_print_resumed(self, *args) -> None:
self._update_state("printing")
self.layer_detect.resume()
self.webcam_stream.reset_ai_scores()
self.webcam_stream.start_ai(self.intervals["ai"])
def _on_print_cancelled(self, *args) -> None:
self._check_job_started(*args)
self._reset_file()
self._send_job_event({"cancelled": True})
self._update_state_from_klippy()
self.cache.job_info = {}
self.layer_detect.stop()
self.webcam_stream.stop_ai()
def _on_print_error(self, *args) -> None:
self._check_job_started(*args)
self._reset_file()
payload: Dict[str, Any] = {"failed": True}
new_stats: Dict[str, Any] = args[1]
msg = new_stats.get("message", "Unknown Error")
payload["error"] = msg
self._send_job_event(payload)
self._update_state_from_klippy()
self.cache.job_info = {}
self.layer_detect.stop()
self.webcam_stream.stop_ai()
def _on_print_complete(self, *args) -> None:
self._check_job_started(*args)
self._reset_file()
self._send_job_event({"finished": True})
self._update_state_from_klippy()
self.cache.job_info = {}
self.layer_detect.stop()
self.webcam_stream.stop_ai()
def _on_print_standby(self, *args) -> None:
self._update_state_from_klippy()
self.cache.job_info = {}
self.layer_detect.stop()
self.webcam_stream.stop_ai()
def _on_pause_requested(self) -> None:
self._print_request_event.set()
if self.cache.state == "printing":
self._update_state("pausing")
def _on_resume_requested(self) -> None:
self._print_request_event.set()
if self.cache.state == "paused":
self._update_state("resuming")
def _on_cancel_requested(self) -> None:
self._print_request_event.set()
if self.cache.state in ["printing", "paused", "pausing"]:
self._update_state("cancelling")
def _on_gcode_response(self, response: str):
if self.gcode_terminal_enabled:
resp = [
r.strip() for r in response.strip().split("\n") if r.strip()
]
self.send_sp("term_update", {"response": resp})
def _on_gcode_received(self, script: str):
if self.gcode_terminal_enabled:
cmds = [s.strip() for s in script.strip().split() if s.strip()]
self.send_sp("term_update", {"command": cmds})
def _on_proc_update(self, proc_stats: Dict[str, Any]) -> None:
cpu = proc_stats["system_cpu_usage"]
if not cpu:
return
curtime = self.eventloop.get_loop_time()
if curtime - self.last_cpu_update_time < self.intervals["cpu"]:
return
self.last_cpu_update_time = curtime
sys_mem = proc_stats["system_memory"]
mem_pct: float = 0.
if sys_mem:
mem_pct = sys_mem["used"] / sys_mem["total"] * 100
cpu_data = {
"usage": int(cpu["cpu"] + .5),
"temp": int(proc_stats["cpu_temp"] + .5),
"memory": int(mem_pct + .5),
"flags": self.cache.throttled_state.get("bits", 0)
}
diff = self._get_object_diff(cpu_data, self.cache.cpu_info)
if diff:
self.cache.cpu_info.update(cpu_data)
self.send_sp("cpu", diff)
def _on_cpu_throttled(self, throttled_state: Dict[str, Any]):
self.cache.throttled_state = throttled_state
def send_status(self, status: Dict[str, Any], eventtime: float) -> None:
for printer_obj, vals in status.items():
self.printer_status[printer_obj].update(vals)
if self.amb_detect.sensor_name in status:
self.amb_detect.update_ambient(
status[self.amb_detect.sensor_name], eventtime
)
self._update_temps(eventtime)
if "bed_mesh" in status:
self._send_mesh_data()
if "toolhead" in status and "extruder" in status["toolhead"]:
self._send_active_extruder(status["toolhead"]["extruder"])
if "gcode_move" in status:
self.layer_detect.update(status["gcode_move"]["gcode_position"])
if self.filament_sensor and self.filament_sensor in status:
detected = status[self.filament_sensor]["filament_detected"]
fstate = "loaded" if detected else "runout"
self.cache.filament_state = fstate
self.send_sp("filament_sensor", {"state": fstate})
def _handle_printer_info_update(self, eventtime: float) -> float:
# Job Info Timer handler
if self.cache.state == "printing":
self._update_job_progress()
return eventtime + self.intervals["job"]
def _handle_sp_ping(self, eventtime: float) -> float:
self._last_sp_ping = eventtime
self.send_sp("ping", None)
return eventtime + self.intervals["ping"]
def _update_job_progress(self) -> None:
job_info: Dict[str, Any] = {}
est_time = self.cache.metadata.get("estimated_time")
last_stats: Dict[str, Any] = self.job_state.get_last_stats()
if est_time is not None:
duration: float = last_stats["print_duration"]
time_left = max(0, int(est_time - duration + .5))
last_time_left = self.cache.job_info.get("time", time_left + 60.)
time_diff = last_time_left - time_left
if (
(time_left < 60 or time_diff >= 30) and
time_left != last_time_left
):
job_info["time"] = time_left
if "display_status" in self.printer_status:
progress = self.printer_status["display_status"]["progress"]
pct_prog = int(progress * 100 + .5)
if pct_prog != self.cache.job_info.get("progress", 0):
job_info["progress"] = int(progress * 100 + .5)
layer: Optional[int] = last_stats.get("info", {}).get("current_layer")
if layer is None:
layer = self.layer_detect.layer
if layer != self.cache.job_info.get("layer", -1):
job_info["layer"] = layer
if job_info:
self.cache.job_info.update(job_info)
self.send_sp("job_info", job_info)
def _update_temps(self, eventtime: float) -> None:
if eventtime < self.next_temp_update_time:
return
need_rapid_update: bool = False
temp_data: Dict[str, List[int]] = {}
for printer_obj, key in self.heaters.items():
reported_temp = self.printer_status[printer_obj]["temperature"]
ret = [
int(reported_temp + .5),
int(self.printer_status[printer_obj]["target"] + .5)
]
last_temps = self.cache.temps.get(key, [-100., -100.])
if ret[1] == last_temps[1]:
if ret[1]:
seeking_target = abs(ret[1] - ret[0]) > 5
else:
seeking_target = ret[0] >= self.amb_detect.ambient + 25
need_rapid_update |= seeking_target
# The target hasn't changed and not heating, debounce temp
if key in self.last_received_temps and not seeking_target:
last_reported = self.last_received_temps[key]
if abs(reported_temp - last_reported) < .75:
self.last_received_temps.pop(key)
continue
if ret[0] == last_temps[0]:
self.last_received_temps[key] = reported_temp
continue
temp_data[key] = ret[:1]
else:
# target has changed, send full data
temp_data[key] = ret
self.last_received_temps[key] = reported_temp
self.cache.temps[key] = ret
if need_rapid_update:
self.next_temp_update_time = (
0. if self.intervals["temps_target"] < .2501 else
eventtime + self.intervals["temps_target"]
)
else:
self.next_temp_update_time = eventtime + self.intervals["temps"]
if not temp_data:
return
self.send_sp("temps", temp_data)
def _update_state_from_klippy(self) -> None:
kstate = self.server.get_klippy_state()
if kstate == "ready":
sp_state = "operational"
elif kstate in ["error", "shutdown"]:
sp_state = "error"
else:
sp_state = "offline"
self._update_state(sp_state)
def _update_state(self, new_state: str) -> None:
if self.cache.state == new_state:
return
self.cache.state = new_state
self.send_sp("state_change", {"new": new_state})
if new_state == "operational":
self.print_handler.notify_ready()
def _send_mesh_data(self) -> None:
mesh = self.printer_status["bed_mesh"]
# TODO: We are probably going to have to reformat the mesh
self.cache.mesh = mesh
self.send_sp("mesh_data", mesh)
def _send_job_event(self, job_info: Dict[str, Any]) -> None:
if self.connected:
self.send_sp("job_info", job_info)
else:
job_info.update(self.cache.job_info)
job_info["delay"] = self.eventloop.get_loop_time()
self.missed_job_events.append(job_info)
if len(self.missed_job_events) > 10:
self.missed_job_events.pop(0)
def _get_ui_info(self) -> Dict[str, Any]:
ui_data: Dict[str, Any] = {"ui": None, "ui_version": None}
self.cache.current_wsid = None
websockets: WebsocketManager
websockets = self.server.lookup_component("websockets")
conns = websockets.get_clients_by_type("web")
if conns:
longest = conns[0]
ui_data["ui"] = longest.client_data["name"]
ui_data["ui_version"] = longest.client_data["version"]
self.cache.current_wsid = longest.uid
return ui_data
async def _send_machine_data(self) -> None:
app_args = self.server.get_app_args()
data = self._get_ui_info()
data["api"] = "Moonraker"
data["api_version"] = app_args["software_version"]
data["sp_version"] = COMPONENT_VERSION
machine: Machine = self.server.lookup_component("machine")
sys_info = machine.get_system_info()
pyver = sys_info["python"]["version"][:3]
data["python_version"] = ".".join([str(part) for part in pyver])
model: str = sys_info["cpu_info"].get("model", "")
if not model or model.isdigit():
model = sys_info["cpu_info"].get("cpu_desc", "Unknown")
data["machine"] = model
data["os"] = sys_info["distribution"].get("name", "Unknown")
pub_intf = await machine.get_public_network()
data["is_ethernet"] = int(not pub_intf["is_wifi"])
data["ssid"] = pub_intf.get("ssid", "")
data["local_ip"] = pub_intf.get("address", "Unknown")
data["hostname"] = pub_intf["hostname"]
data["core_count"] = os.cpu_count()
mem = sys_info["cpu_info"]["total_memory"]
if mem is not None:
data["total_memory"] = mem * 1024
self.cache.machine_info = data
self.send_sp("machine_data", data)
def _send_firmware_data(self) -> None:
kinfo = self.server.get_klippy_info()
if "software_version" not in kinfo:
return
firmware_date: str = ""
# Approximate the firmware "date" using the last modified
# time of the Klippy source folder
kpath = pathlib.Path(kinfo["klipper_path"]).joinpath("klippy")
if kpath.is_dir():
mtime = kpath.stat().st_mtime
firmware_date = time.asctime(time.gmtime(mtime))
version: str = kinfo["software_version"]
unsafe = version.endswith("-dirty") or version == "?"
if unsafe:
version = version.rsplit("-", 1)[0]
fw_info = {
"firmware": "Klipper",
"firmware_version": version,
"firmware_date": firmware_date,
"firmware_link": "https://github.com/Klipper3d/klipper",
}
diff = self._get_object_diff(fw_info, self.cache.firmware_info)
if diff:
self.cache.firmware_info = fw_info
self.send_sp(
"firmware", {"fw": diff, "raw": False, "unsafe": unsafe}
)
def _send_active_extruder(self, new_extruder: str):
tool = "T0" if new_extruder == "extruder" else f"T{new_extruder[8:]}"
if tool == self.cache.active_extruder:
return
self.cache.active_extruder = tool
self.send_sp("tool", {"new": tool})
async def _send_webcam_config(self) -> None:
wc_cfg = await self.webcam_stream.get_webcam_config()
wc_data = {
"flipH": wc_cfg.get("flip_horizontal", False),
"flipV": wc_cfg.get("flip_vertical", False),
"rotate90": wc_cfg.get("rotation", 0) == 90
}
self.send_sp("webcam", wc_data)
async def _send_power_state(self) -> None:
dev_info: Optional[Dict[str, Any]]
dev_info = await self._call_internal_api(
"machine.device_power.get_device", device=self.power_id
)
if dev_info is not None:
is_on = dev_info[self.power_id] == "on"
self.send_sp("power_controller", {"on": is_on})
def _push_initial_state(self):
self.send_sp("state_change", {"new": self.cache.state})
if self.cache.temps:
self.send_sp("temps", self.cache.temps)
if self.cache.firmware_info:
self.send_sp(
"firmware",
{"fw": self.cache.firmware_info, "raw": False})
curtime = self.eventloop.get_loop_time()
for evt in self.missed_job_events:
evt["delay"] = int((curtime - evt["delay"]) + .5)
self.send_sp("job_info", evt)
self.missed_job_events = []
if self.cache.active_extruder:
self.send_sp("tool", {"new": self.cache.active_extruder})
if self.cache.cpu_info:
self.send_sp("cpu_info", self.cache.cpu_info)
self.send_sp("ambient", {"new": self.amb_detect.ambient})
if self.power_id:
self.eventloop.create_task(self._send_power_state())
if self.cache.filament_state:
self.send_sp(
"filament_sensor", {"state": self.cache.filament_state}
)
self.send_sp(
"webcam_status", {"connected": self.webcam_stream.connected}
)
self.eventloop.create_task(self._send_machine_data())
self.eventloop.create_task(self._send_webcam_config())
def _check_setup_event(self, evt_name: str) -> bool:
return self.is_set_up or evt_name in PRE_SETUP_EVENTS
def send_sp(self, evt_name: str, data: Any) -> Awaitable[bool]:
if (
not self.connected or
self.ws is None or
self.ws.protocol is None or
not self._check_setup_event(evt_name)
):
fut = self.eventloop.create_future()
fut.set_result(False)
return fut
packet = {"type": evt_name, "data": data}
return self.eventloop.create_task(self._send_wrapper(packet))
async def _send_wrapper(self, packet: Dict[str, Any]) -> bool:
try:
assert self.ws is not None
await self.ws.write_message(json.dumps(packet))
except Exception:
return False
else:
if packet["type"] != "stream":
self._logger.info(f"sent: {packet}")
else:
self._logger.info("sent: webcam stream")
return True
def _get_object_diff(
self, new_obj: Dict[str, Any], cached_obj: Dict[str, Any]
) -> Dict[str, Any]:
if not cached_obj:
return new_obj
diff: Dict[str, Any] = {}
for key, val in new_obj.items():
if key in cached_obj and val == cached_obj[key]:
continue
diff[key] = val
return diff
async def close(self):
self.print_handler.cancel()
self.webcam_stream.stop_ai()
self.amb_detect.stop()
self.printer_info_timer.stop()
self.ping_sp_timer.stop()
await self.send_sp("shutdown", None)
self._logger.close()
self.is_closing = True
if self.ws is not None:
self.ws.close(1001, "Client Shutdown")
if (
self.connection_task is not None and
not self.connection_task.done()
):
try:
await asyncio.wait_for(self.connection_task, 2.)
except asyncio.TimeoutError:
pass
class ReportCache:
def __init__(self) -> None:
self.state = "offline"
self.temps: Dict[str, Any] = {}
self.metadata: Dict[str, Any] = {}
self.mesh: Dict[str, Any] = {}
self.job_info: Dict[str, Any] = {}
self.active_extruder: str = ""
# Persistent state across connections
self.firmware_info: Dict[str, Any] = {}
self.machine_info: Dict[str, Any] = {}
self.cpu_info: Dict[str, Any] = {}
self.throttled_state: Dict[str, Any] = {}
self.current_wsid: Optional[int] = None
self.filament_state: str = ""
def reset_print_state(self) -> None:
self.temps = {}
self.mesh = {}
self.job_info = {}
INITIAL_AMBIENT = 85
AMBIENT_CHECK_TIME = 5. * 60.
TARGET_CHECK_TIME = 60. * 60.
SAMPLE_CHECK_TIME = 20.
class AmbientDetect:
CHECK_INTERVAL = 5
def __init__(
self,
config: ConfigHelper,
simplyprint: SimplyPrint,
initial_ambient: int
) -> None:
self.server = config.get_server()
self.simplyprint = simplyprint
self.cache = simplyprint.cache
self._initial_sample: int = -1000
self._ambient = initial_ambient
self._last_sample_time: float = 0.
self._update_interval = AMBIENT_CHECK_TIME
self.eventloop = self.server.get_event_loop()
self._detect_timer = self.eventloop.register_timer(
self._handle_detect_timer
)
self._sensor_name: str = config.get("ambient_sensor", "")
@property
def ambient(self) -> int:
return self._ambient
@property
def sensor_name(self) -> str:
return self._sensor_name
def update_ambient(
self, sensor_info: Dict[str, Any], eventtime: float = SAMPLE_CHECK_TIME
) -> None:
if "temperature" not in sensor_info:
return
if eventtime < self._last_sample_time + SAMPLE_CHECK_TIME:
return
self._last_sample_time = eventtime
new_amb = int(sensor_info["temperature"] + .5)
if abs(new_amb - self._ambient) < 2:
return
self._ambient = new_amb
self._on_ambient_changed(self._ambient)
def _handle_detect_timer(self, eventtime: float) -> float:
if "tool0" not in self.cache.temps:
self._initial_sample = -1000
return eventtime + self.CHECK_INTERVAL
temp, target = self.cache.temps["tool0"]
if target:
self._initial_sample = -1000
self._last_sample_time = eventtime
self._update_interval = TARGET_CHECK_TIME
return eventtime + self.CHECK_INTERVAL
if eventtime - self._last_sample_time < self._update_interval:
return eventtime + self.CHECK_INTERVAL
if self._initial_sample == -1000:
self._initial_sample = temp
self._update_interval = SAMPLE_CHECK_TIME
else:
diff = abs(temp - self._initial_sample)
if diff <= 2:
last_ambient = self._ambient
self._ambient = int((temp + self._initial_sample) / 2 + .5)
self._initial_sample = -1000
self._last_sample_time = eventtime
self._update_interval = AMBIENT_CHECK_TIME
if last_ambient != self._ambient:
logging.debug(f"SimplyPrint: New Ambient: {self._ambient}")
self._on_ambient_changed(self._ambient)
else:
self._initial_sample = temp
self._update_interval = SAMPLE_CHECK_TIME
return eventtime + self.CHECK_INTERVAL
def _on_ambient_changed(self, new_ambient: int) -> None:
self.simplyprint.save_item("ambient_temp", new_ambient)
self.simplyprint.send_sp("ambient", {"new": new_ambient})
def start(self) -> None:
if self._detect_timer.is_running():
return
if "tool0" in self.cache.temps:
cur_temp = self.cache.temps["tool0"][0]
if cur_temp < self._ambient:
self._ambient = cur_temp
self._on_ambient_changed(self._ambient)
self._detect_timer.start()
def stop(self) -> None:
self._detect_timer.stop()
self._last_sample_time = 0.
class LayerDetect:
def __init__(self) -> None:
self._layer: int = 0
self._layer_z: float = 0.
self._active: bool = False
self._layer_height: float = 0.
self._fl_height: float = 0.
self._layer_count: int = 99999999999
self._check_next: bool = False
@property
def layer(self) -> int:
return self._layer
def update(self, new_pos: List[float]) -> None:
if not self._active or self._layer_z == new_pos[2]:
self._check_next = False
return
if not self._check_next:
# Try to avoid z-hops by skipping the first detected change
self._check_next = True
return
self._check_next = False
layer = 1 + int(
(new_pos[2] - self._fl_height) / self._layer_height + .5
)
self._layer = min(layer, self._layer_count)
self._layer_z = new_pos[2]
def start(self, metadata: Dict[str, Any]) -> None:
self.reset()
lh: Optional[float] = metadata.get("layer_height")
flh: Optional[float] = metadata.get("first_layer_height", lh)
if lh is not None and flh is not None:
self._active = True
self._layer_height = lh
self._fl_height = flh
layer_count: Optional[int] = metadata.get("layer_count")
obj_height: Optional[float] = metadata.get("object_height")
if layer_count is not None:
self._layer_count = layer_count
elif obj_height is not None:
self._layer_count = int((obj_height - flh) / lh + .5)
def resume(self) -> None:
self._active = True
def stop(self) -> None:
self._active = False
def reset(self) -> None:
self._active = False
self._layer = 0
self._layer_z = 0.
self._layer_height = 0.
self._fl_height = 0.
self._layer_count = 99999999999
self._check_next = False
# TODO: We need to get the URL/Port from settings in the future.
# Ideally we will always fetch from the localhost rather than
# go through the reverse proxy
FALLBACK_URL = "http://127.0.0.1:8080/?action=snapshot"
SP_SNAPSHOT_URL = "https://api.simplyprint.io/jobs/ReceiveSnapshot"
SP_AI_URL = "https://ai.simplyprint.io/api/v2/infer"
class WebcamStream:
def __init__(
self, config: ConfigHelper, simplyprint: SimplyPrint
) -> None:
self.server = config.get_server()
self.eventloop = self.server.get_event_loop()
self.simplyprint = simplyprint
self.webcam_name = config.get("webcam_name", "")
self.url = FALLBACK_URL
self.client: HttpClient = self.server.lookup_component("http_client")
self.cam: Optional[WebCam] = None
self._connected = False
self.ai_running = False
self.ai_task: Optional[asyncio.Task] = None
self.ai_scores: List[Any] = []
self.failed_ai_attempts = 0
@property
def connected(self) -> bool:
return self._connected
async def intialize_url(self) -> None:
wcmgr: WebcamManager = self.server.lookup_component("webcam")
cams = wcmgr.get_webcams()
if not cams:
# no camera configured, try the fallback url
return
if self.webcam_name and self.webcam_name in cams:
cam = cams[self.webcam_name]
else:
cam = list(cams.values())[0]
try:
url = await cam.get_snapshot_url(True)
except Exception:
logging.exception("Failed to retrive webcam url")
return
self.cam = cam
logging.info(f"SimplyPrint Webcam URL assigned: {url}")
self.url = url
async def test_connection(self):
if not self.url.startswith("http"):
self._connected = False
return
headers = {"Accept": "image/jpeg"}
resp = await self.client.get(self.url, headers, enable_cache=False)
self._connected = not resp.has_error()
async def get_webcam_config(self) -> Dict[str, Any]:
if self.cam is None:
return {}
return self.cam.as_dict()
async def extract_image(self) -> str:
headers = {"Accept": "image/jpeg"}
resp = await self.client.get(self.url, headers, enable_cache=False)
resp.raise_for_status()
return await self.eventloop.run_in_thread(
self._encode_image, resp.content
)
def _encode_image(self, image: bytes) -> str:
return base64.b64encode(image).decode()
async def post_image(self, payload: Dict[str, Any]) -> None:
uid: Optional[str] = payload.get("id")
timer: Optional[int] = payload.get("timer")
try:
if uid is not None:
url = payload.get("endpoint", SP_SNAPSHOT_URL)
img = await self.extract_image()
headers = {
"User-Agent": "Mozilla/5.0",
"Content-Type": "application/x-www-form-urlencoded"
}
body = f"id={url_escape(uid)}&image={url_escape(img)}"
resp = await self.client.post(
url, body=body, headers=headers, enable_cache=False
)
resp.raise_for_status()
elif timer is not None:
await asyncio.sleep(timer / 1000)
img = await self.extract_image()
self.simplyprint.send_sp("stream", {"base": img})
except asyncio.CancelledError:
raise
except Exception as e:
if not self.server.is_verbose_enabled():
return
logging.exception("SimplyPrint WebCam Stream Error")
async def _send_ai_image(self, base_image: str) -> None:
interval = self.simplyprint.intervals["ai"]
headers = {"User-Agent": "Mozilla/5.0"}
data = {
"api_key": self.simplyprint.sp_info["printer_token"],
"image_array": base_image,
"interval": interval,
"printer_id": self.simplyprint.sp_info["printer_id"],
"settings": {
"buffer_percent": 80,
"confidence": 60,
"buffer_length": 16
},
"scores": self.ai_scores
}
resp = await self.client.post(
SP_AI_URL, body=data, headers=headers, enable_cache=False
)
resp.raise_for_status()
self.failed_ai_attempts = 0
resp_json = resp.json()
if isinstance(resp_json, dict):
self.ai_scores = resp_json.get("scores", self.ai_scores)
ai_result = resp_json.get("s1", [0, 0, 0])
self.simplyprint.send_sp("ai_resp", {"ai": ai_result})
async def _ai_stream(self, delay: float) -> None:
if delay:
await asyncio.sleep(delay)
while self.ai_running:
interval = self.simplyprint.intervals["ai"]
try:
img = await self.extract_image()
await self._send_ai_image(img)
except asyncio.CancelledError:
raise
except Exception:
self.failed_ai_attempts += 1
if self.failed_ai_attempts == 1:
logging.exception("SimplyPrint AI Stream Error")
elif not self.failed_ai_attempts % 10:
logging.info(
f"SimplyPrint: {self.failed_ai_attempts} consecutive "
"AI failures"
)
delay = min(120., self.failed_ai_attempts * 5.0)
interval = self.simplyprint.intervals["ai"] + delay
await asyncio.sleep(interval)
def reset_ai_scores(self):
self.ai_scores = []
def start_ai(self, delay: float = 0) -> None:
if self.ai_running:
self.stop_ai()
self.ai_running = True
self.ai_task = self.eventloop.create_task(self._ai_stream(delay))
def stop_ai(self) -> None:
if not self.ai_running:
return
self.ai_running = False
if self.ai_task is not None:
self.ai_task.cancel()
self.ai_task = None
class PrintHandler:
def __init__(self, simplyprint: SimplyPrint) -> None:
self.simplyprint = simplyprint
self.server = simplyprint.server
self.eventloop = self.server.get_event_loop()
self.cache = simplyprint.cache
self.download_task: Optional[asyncio.Task] = None
self.print_ready_event: asyncio.Event = asyncio.Event()
self.download_progress: int = -1
self.pending_file: str = ""
self.last_started: str = ""
def download_file(self, url: str, start: bool):
coro = self._download_sp_file(url, start)
self.download_task = self.eventloop.create_task(coro)
def cancel(self):
if (
self.download_task is not None and
not self.download_task.done()
):
self.download_task.cancel()
self.download_task = None
def notify_ready(self):
self.print_ready_event.set()
async def _download_sp_file(self, url: str, start: bool):
client: HttpClient = self.server.lookup_component("http_client")
fm: FileManager = self.server.lookup_component("file_manager")
gc_path = pathlib.Path(fm.get_directory())
if not gc_path.is_dir():
logging.debug(f"GCode Path Not Registered: {gc_path}")
self.simplyprint.send_sp(
"file_progress",
{"state": "error", "message": "GCode Path not Registered"}
)
return
accept = "text/plain,applicaton/octet-stream"
self._on_download_progress(0, 0, 0)
try:
logging.debug(f"Downloading URL: {url}")
tmp_path = await client.download_file(
url, accept, progress_callback=self._on_download_progress,
request_timeout=3600.
)
except asyncio.TimeoutError:
raise
except Exception:
logging.exception(f"Failed to download file: {url}")
self.simplyprint.send_sp(
"file_progress",
{"state": "error", "message": "Network Error"}
)
return
finally:
self.download_progress = -1
logging.debug("Simplyprint: Download Complete")
filename = pathlib.PurePath(tmp_path.name)
fpath = gc_path.joinpath(filename.name)
if self.cache.job_info.get("filename", "") == str(fpath):
# This is an attempt to overwite a print in progress, make a copy
count = 0
while fpath.exists():
name = f"{filename.stem}_copy_{count}.{filename.suffix}"
fpath = gc_path.joinpath(name)
count += 1
args: Dict[str, Any] = {
"filename": fpath.name,
"tmp_file_path": str(tmp_path),
}
state = "pending"
if self.cache.state == "operational":
args["print"] = "true" if start else "false"
try:
ret = await fm.finalize_upload(args)
except self.server.error as e:
logging.exception("GCode Finalization Failed")
self.simplyprint.send_sp(
"file_progress",
{"state": "error", "message": f"GCode Finalization Failed: {e}"}
)
return
self.pending_file = fpath.name
if ret.get("print_started", False):
state = "started"
self.last_started = self.pending_file
self.pending_file = ""
elif not start and await self._check_can_print():
state = "ready"
if state == "pending":
self.print_ready_event.clear()
try:
await asyncio.wait_for(self.print_ready_event.wait(), 10.)
except asyncio.TimeoutError:
self.pending_file = ""
self.simplyprint.send_sp(
"file_progress",
{"state": "error", "message": "Pending print timed out"}
)
return
else:
if start:
await self.start_print()
return
state = "ready"
self.simplyprint.send_sp("file_progress", {"state": state})
async def start_print(self) -> None:
if not self.pending_file:
return
pending = self.pending_file
self.pending_file = ""
kapi: KlippyAPI = self.server.lookup_component("klippy_apis")
data = {"state": "started"}
try:
await kapi.start_print(pending)
except Exception:
logging.exception("Print Failed to start")
data["state"] = "error"
data["message"] = "Failed to start print"
else:
self.last_started = pending
self.simplyprint.send_sp("file_progress", data)
async def _check_can_print(self) -> bool:
if self.server.get_klippy_state() != "ready":
return False
kapi: KlippyAPI = self.server.lookup_component("klippy_apis")
try:
result = await kapi.query_objects({"print_stats": None})
except Exception:
# Klippy not connected
return False
if 'print_stats' not in result:
return False
state: str = result['print_stats']['state']
if state in ["printing", "paused"]:
return False
return True
def _on_download_progress(self, percent: int, size: int, recd: int) -> None:
if percent == self.download_progress:
return
self.download_progress = percent
self.simplyprint.send_sp(
"file_progress", {"state": "downloading", "percent": percent}
)
class ProtoLogger:
def __init__(self, config: ConfigHelper) -> None:
server = config.get_server()
self._logger: Optional[logging.Logger] = None
if not server.is_verbose_enabled():
return
fm: FileManager = server.lookup_component("file_manager")
log_root = fm.get_directory("logs")
if log_root:
log_parent = pathlib.Path(log_root)
else:
log_parent = pathlib.Path(tempfile.gettempdir())
log_path = log_parent.joinpath("simplyprint.log")
queue: SimpleQueue = SimpleQueue()
self.queue_handler = LocalQueueHandler(queue)
self._logger = logging.getLogger("simplyprint")
self._logger.addHandler(self.queue_handler)
self._logger.propagate = False
file_hdlr = logging.handlers.TimedRotatingFileHandler(
log_path, when='midnight', backupCount=2)
formatter = logging.Formatter(
'%(asctime)s [%(funcName)s()] - %(message)s')
file_hdlr.setFormatter(formatter)
self.qlistner = logging.handlers.QueueListener(queue, file_hdlr)
self.qlistner.start()
def info(self, msg: str) -> None:
if self._logger is None:
return
self._logger.info(msg)
def debug(self, msg: str) -> None:
if self._logger is None:
return
self._logger.debug(msg)
def warning(self, msg: str) -> None:
if self._logger is None:
return
self._logger.warning(msg)
def exception(self, msg: str) -> None:
if self._logger is None:
return
self._logger.exception(msg)
def close(self):
if self._logger is None:
return
self._logger.removeHandler(self.queue_handler)
self.qlistner.stop()
self._logger = None
def load_component(config: ConfigHelper) -> SimplyPrint:
return SimplyPrint(config)