# SimplyPrint Connection Support # # Copyright (C) 2022 Eric Callahan # # 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 from websockets import Subscribable, WebRequest import logging.handlers import tempfile from queue import SimpleQueue from utils import LocalQueueHandler 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, WebSocket from tornado.websocket import WebSocketClientConnection from components.database import MoonrakerDatabase from components.klippy_apis import KlippyAPI from components.job_state import JobState from components.machine import Machine from components.file_manager.file_manager import FileManager from components.http_client import HttpClient from components.power import PrinterPower from components.announcements import Announcements from components.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 # TODO: default use_test_endpoint to false upon release self.test = config.get("use_test_endpoint", True) 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:websocket_identified", self._on_websocket_identified) self.server.register_event_handler( "websockets:websocket_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): 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: Dict[str] = 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 status: Dict[str, Any] # 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: WebSocket) -> 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: WebSocket) -> 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_websockets_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): 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): 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://apirewrite.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): 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 url = client.escape_url(url) 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): 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)