spoolman: refactor tracking

Use a python dict to act as a queue for reporting used filament
per spool.  This eliminates the need for locks and resolves
potential issues with spool changes when the Spoolman
service is not available.

In addition, add support for tracking multiple tools

Signed-off-by:  Eric Callahan <arksine.code@gmail.com>
This commit is contained in:
Eric Callahan 2024-01-19 15:08:59 -05:00
parent 045075c396
commit d4316d9878
No known key found for this signature in database
GPG Key ID: 5A1EB336DFB4C71B
1 changed files with 79 additions and 85 deletions

View File

@ -10,7 +10,7 @@ import logging
import re import re
import contextlib import contextlib
import tornado.websocket as tornado_ws import tornado.websocket as tornado_ws
from ..common import RequestType, Sentinel from ..common import RequestType
from ..utils import json_wrapper as jsonw from ..utils import json_wrapper as jsonw
from typing import ( from typing import (
TYPE_CHECKING, TYPE_CHECKING,
@ -32,7 +32,6 @@ if TYPE_CHECKING:
DB_NAMESPACE = "moonraker" DB_NAMESPACE = "moonraker"
ACTIVE_SPOOL_KEY = "spoolman.spool_id" ACTIVE_SPOOL_KEY = "spoolman.spool_id"
CONNECTION_ERROR_LOG_TIME = 60.
class SpoolManager: class SpoolManager:
def __init__(self, config: ConfigHelper): def __init__(self, config: ConfigHelper):
@ -40,19 +39,18 @@ class SpoolManager:
self.eventloop = self.server.get_event_loop() self.eventloop = self.server.get_event_loop()
self._get_spoolman_urls(config) self._get_spoolman_urls(config)
self.sync_rate_seconds = config.getint("sync_rate", default=5, minval=1) self.sync_rate_seconds = config.getint("sync_rate", default=5, minval=1)
self.last_sync_time = 0. self.report_timer = self.eventloop.register_timer(self.report_extrusion)
self.extruded_lock = asyncio.Lock() self.pending_reports: Dict[int, float] = {}
self.spoolman_ws: Optional[WebSocketClientConnection] = None self.spoolman_ws: Optional[WebSocketClientConnection] = None
self.connection_task: Optional[asyncio.Task] = None self.connection_task: Optional[asyncio.Task] = None
self.spool_check_task: Optional[asyncio.Task] = None self.spool_check_task: Optional[asyncio.Task] = None
self.spool_lock = asyncio.Lock()
self.ws_connected: bool = False self.ws_connected: bool = False
self.reconnect_delay: float = 2. self.reconnect_delay: float = 2.
self.is_closing: bool = False self.is_closing: bool = False
self.spool_id: Optional[int] = None self.spool_id: Optional[int] = None
self.extruded: float = 0
self._error_logged: bool = False self._error_logged: bool = False
self._highest_epos: float = 0 self._highest_epos: float = 0
self._current_extruder: str = "extruder"
self.klippy_apis: APIComp = self.server.lookup_component("klippy_apis") self.klippy_apis: APIComp = self.server.lookup_component("klippy_apis")
self.http_client: HttpClient = self.server.lookup_component("http_client") self.http_client: HttpClient = self.server.lookup_component("http_client")
self.database: MoonrakerDatabase = self.server.lookup_component("database") self.database: MoonrakerDatabase = self.server.lookup_component("database")
@ -130,6 +128,7 @@ class SpoolManager:
else: else:
self.ws_connected = True self.ws_connected = True
self._error_logged = False self._error_logged = False
self.report_timer.start()
logging.info("Connected to Spoolman Spool Manager") logging.info("Connected to Spoolman Spool Manager")
if self.spool_id is not None: if self.spool_id is not None:
self._cancel_spool_check_task() self._cancel_spool_check_task()
@ -148,6 +147,7 @@ class SpoolManager:
if isinstance(message, str): if isinstance(message, str):
self._decode_message(message) self._decode_message(message)
elif message is None: elif message is None:
self.report_timer.stop()
self.ws_connected = False self.ws_connected = False
cur_time = self.eventloop.get_loop_time() cur_time = self.eventloop.get_loop_time()
ping_time: float = cur_time - self._last_ping_received ping_time: float = cur_time - self._last_ping_received
@ -169,7 +169,8 @@ class SpoolManager:
if self.spool_id is not None and event.get("type") == "deleted": if self.spool_id is not None and event.get("type") == "deleted":
payload: Dict[str, Any] = event.get("payload", {}) payload: Dict[str, Any] = event.get("payload", {})
if payload.get("id") == self.spool_id: if payload.get("id") == self.spool_id:
self.eventloop.create_task(self.set_active_spool(Sentinel.MISSING)) self.pending_reports.pop(self.spool_id, None)
self.set_active_spool(None)
def _cancel_spool_check_task(self) -> None: def _cancel_spool_check_task(self) -> None:
if self.spool_check_task is None or self.spool_check_task.done(): if self.spool_check_task is None or self.spool_check_task.done():
@ -184,7 +185,8 @@ class SpoolManager:
) )
if response.status_code == 404: if response.status_code == 404:
logging.info(f"Spool ID {self.spool_id} not found, setting to None") logging.info(f"Spool ID {self.spool_id} not found, setting to None")
await self.set_active_spool(Sentinel.MISSING) self.pending_reports.pop(self.spool_id, None)
self.set_active_spool(None)
elif response.has_error(): elif response.has_error():
err_msg = self._get_response_error(response) err_msg = self._get_response_error(response)
logging.info(f"Attempt to check spool status failed: {err_msg}") logging.info(f"Attempt to check spool status failed: {err_msg}")
@ -198,11 +200,14 @@ class SpoolManager:
def _on_ws_ping(self, data: bytes = b"") -> None: def _on_ws_ping(self, data: bytes = b"") -> None:
self._last_ping_received = self.eventloop.get_loop_time() self._last_ping_received = self.eventloop.get_loop_time()
async def _handle_klippy_ready(self): async def _handle_klippy_ready(self) -> None:
result: Dict[str, Dict[str, Any]]
result = await self.klippy_apis.subscribe_objects( result = await self.klippy_apis.subscribe_objects(
{"toolhead": ["position"]}, self._handle_status_update, {} {"toolhead": ["position", "extruder"]}, self._handle_status_update, {}
) )
initial_e_pos = self._eposition_from_status(result) toolhead = result.get("toolhead", {})
self._current_extruder = toolhead.get("extruder", "extruder")
initial_e_pos = toolhead.get("position", [None]*4)[3]
logging.debug(f"Initial epos: {initial_e_pos}") logging.debug(f"Initial epos: {initial_e_pos}")
if initial_e_pos is not None: if initial_e_pos is not None:
self._highest_epos = initial_e_pos self._highest_epos = initial_e_pos
@ -217,41 +222,31 @@ class SpoolManager:
err_msg += f", Spoolman message: {msg}" err_msg += f", Spoolman message: {msg}"
return err_msg return err_msg
def _eposition_from_status(self, status: Dict[str, Any]) -> Optional[float]: def _handle_status_update(self, status: Dict[str, Any], _: float) -> None:
position = status.get("toolhead", {}).get("position", []) toolhead: Optional[Dict[str, Any]] = status.get("toolhead")
return position[3] if len(position) > 3 else None if toolhead is None:
return
async def _handle_status_update(self, status: Dict[str, Any], _: float) -> None: epos: float = toolhead.get("position", [0, 0, 0, self._highest_epos])[3]
epos = self._eposition_from_status(status) extr = toolhead.get("extruder", self._current_extruder)
if epos and epos > self._highest_epos: if extr != self._current_extruder:
async with self.extruded_lock: self._highest_epos = epos
self.extruded += epos - self._highest_epos self._current_extruder = extr
elif epos > self._highest_epos:
if self.spool_id is not None:
self._add_extrusion(self.spool_id, epos - self._highest_epos)
self._highest_epos = epos self._highest_epos = epos
now = self.eventloop.get_loop_time() def _add_extrusion(self, spool_id: int, used_length: float) -> None:
difference = now - self.last_sync_time if spool_id in self.pending_reports:
if difference > self.sync_rate_seconds: self.pending_reports[spool_id] += used_length
self.last_sync_time = now else:
logging.debug("Sync period elapsed, tracking usage") self.pending_reports[spool_id] = used_length
await self.track_filament_usage()
async def set_active_spool(self, spool_id: Union[int, Sentinel, None]) -> None: def set_active_spool(self, spool_id: Union[int, None]) -> None:
async with self.spool_lock: assert spool_id is None or isinstance(spool_id, int)
deleted_spool = False
if spool_id is Sentinel.MISSING:
spool_id = None
deleted_spool = True
if self.spool_id == spool_id: if self.spool_id == spool_id:
logging.info(f"Spool ID already set to: {spool_id}") logging.info(f"Spool ID already set to: {spool_id}")
return return
# Store the current spool usage before switching, unless it has been deleted
if not deleted_spool:
if self.spool_id is not None:
await self.track_filament_usage()
elif spool_id is not None:
# No need to track, just reset extrusion
async with self.extruded_lock:
self.extruded = 0
self.spool_id = spool_id self.spool_id = spool_id
self.database.insert_item(DB_NAMESPACE, ACTIVE_SPOOL_KEY, spool_id) self.database.insert_item(DB_NAMESPACE, ACTIVE_SPOOL_KEY, spool_id)
self.server.send_event( self.server.send_event(
@ -259,51 +254,49 @@ class SpoolManager:
) )
logging.info(f"Setting active spool to: {spool_id}") logging.info(f"Setting active spool to: {spool_id}")
async def track_filament_usage(self): async def report_extrusion(self, eventtime: float) -> float:
spool_id = self.spool_id if not self.ws_connected:
if spool_id is None: return eventtime + self.sync_rate_seconds
logging.debug("No active spool, skipping tracking") pending_reports = self.pending_reports
return self.pending_reports = {}
async with self.extruded_lock: for spool_id, used_length in pending_reports.items():
if self.extruded > 0 and self.ws_connected: if not self.ws_connected:
used_length = self.extruded self._add_extrusion(spool_id, used_length)
continue
logging.debug( logging.debug(
f"Sending spool usage: " f"Sending spool usage: ID: {spool_id}, Length: {used_length:.3f}mm"
f"ID: {spool_id}, "
f"Length: {used_length:.3f}mm, "
) )
response = await self.http_client.request( response = await self.http_client.request(
method="PUT", method="PUT",
url=f"{self.spoolman_url}/v1/spool/{spool_id}/use", url=f"{self.spoolman_url}/v1/spool/{spool_id}/use",
body={ body={"use_length": used_length}
"use_length": used_length,
},
) )
if response.has_error(): if response.has_error():
if response.status_code == 404: if response.status_code == 404:
self._error_logged = False # Since the spool is deleted we can remove any pending reports
logging.info( # added while waiting for the request
f"Spool ID {self.spool_id} not found, setting to None" self.pending_reports.pop(spool_id, None)
) if spool_id == self.spool_id:
coro = self.set_active_spool(Sentinel.MISSING) logging.info(f"Spool ID {spool_id} not found, setting to None")
self.eventloop.create_task(coro) self.set_active_spool(None)
elif not self._error_logged: else:
if not self._error_logged:
error_msg = self._get_response_error(response) error_msg = self._get_response_error(response)
self._error_logged = True self._error_logged = True
logging.info( logging.info(
f"Failed to update extrusion for spool id {spool_id}, " f"Failed to update extrusion for spool id {spool_id}, "
f"received {error_msg}" f"received {error_msg}"
) )
return # Add missed reports back to pending reports for the next cycle
self._add_extrusion(spool_id, used_length)
continue
self._error_logged = False self._error_logged = False
self.extruded = 0 return self.eventloop.get_loop_time() + self.sync_rate_seconds
async def _handle_spool_id_request(self, web_request: WebRequest): async def _handle_spool_id_request(self, web_request: WebRequest):
if web_request.get_request_type() == RequestType.POST: if web_request.get_request_type() == RequestType.POST:
spool_id = web_request.get_int("spool_id", None) spool_id = web_request.get_int("spool_id", None)
await self.set_active_spool(spool_id) self.set_active_spool(spool_id)
# For GET requests we will simply return the spool_id # For GET requests we will simply return the spool_id
return {"spool_id": self.spool_id} return {"spool_id": self.spool_id}
@ -362,6 +355,7 @@ class SpoolManager:
async def close(self): async def close(self):
self.is_closing = True self.is_closing = True
self.report_timer.stop()
if self.spoolman_ws is not None: if self.spoolman_ws is not None:
self.spoolman_ws.close(1001, "Moonraker Shutdown") self.spoolman_ws.close(1001, "Moonraker Shutdown")
self._cancel_spool_check_task() self._cancel_spool_check_task()