Eric Callahan bfeb096f31
jsonrpc: share one instance among all transports
This change refactors the APIDefiniton into a dataclass, allowing
defs to be shared directly among HTTP and RPC requests.  In
addition, all transports now share one instance of JSONRPC,
removing duplicate registration.  API Defintiions are registered
with the RPC Dispatcher, and it validates the Transport type.
In addition tranports may perform their own validation prior
to request execution.

Signed-off-by:  Eric Callahan <arksine.code@gmail.com>
2023-12-16 16:21:21 -05:00

1697 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 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 APITransport, WebRequest, JobEvent, KlippyState
from ..utils import json_wrapper as jsonw
from typing import (
TYPE_CHECKING,
Awaitable,
Optional,
Dict,
List,
Union,
Any,
Callable,
)
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(APITransport):
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:state_changed", self._on_job_state_changed)
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("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] = jsonw.loads(msg)
except jsonw.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("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("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("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: List[str] = args.get("list", [])
ident: Optional[str] = args.get("identifier", None)
if script_list:
script = "\n".join(script_list)
self.eventloop.create_task(self._handle_gcode_demand(script, ident))
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("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 _handle_gcode_demand(
self, script: str, ident: Optional[str]
) -> None:
success: bool = True
msg: Optional[str] = None
try:
await self.klippy_apis.run_gcode(script)
except self.server.error as e:
msg = str(e)
success = False
if ident is not None:
self.send_sp(
"gcode_executed",
{
"identifier": ident,
"success": success,
"message": msg
}
)
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_started(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, transport=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: KlippyState) -> None:
if state != KlippyState.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_job_state_changed(self, job_event: JobEvent, *args) -> None:
callback: Optional[Callable] = getattr(self, f"_on_print_{job_event}", None)
if callback is not None:
callback(*args)
else:
logging.info(f"No defined callback for Job Event: {job_event}")
def _on_print_started(
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),
"memory": int(mem_pct + .5),
"flags": self.cache.throttled_state.get("bits", 0)
}
temp: Optional[float] = proc_stats["cpu_temp"]
if temp is not None:
cpu_data["temp"] = int(temp + .5)
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:
kconn: KlippyConnection = self.server.lookup_component("klippy_connection")
klippy_state = kconn.state
if klippy_state == KlippyState.READY:
sp_state = "operational"
elif klippy_state in [KlippyState.ERROR, KlippyState.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(jsonw.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:
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:
kconn: KlippyConnection = self.server.lookup_component("klippy_connection")
if kconn.state != KlippyState.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)