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