Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added basic integration to the Spoolman filament manager #651

Closed
wants to merge 18 commits into from
Closed
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 194 additions & 0 deletions moonraker/components/spoolman.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
# Integration with Spoolman
#
# Copyright (C) 2023 Daniel Hultgren <[email protected]>
#
# This file may be distributed under the terms of the GNU GPLv3 license.

from __future__ import annotations
import asyncio
import datetime
import logging
from typing import TYPE_CHECKING, Dict, Any

if TYPE_CHECKING:
from typing import Optional
from moonraker.websockets import WebRequest
from moonraker.components.http_client import HttpClient
from moonraker.components.database import MoonrakerDatabase
from moonraker.utils import ServerError
from .klippy_apis import KlippyAPI as APIComp
from confighelper import ConfigHelper

DB_NAMESPACE = "moonraker"
ACTIVE_SPOOL_KEY = "spoolman.spool_id"


class SpoolManager:
spool_id: Optional[int] = None
highest_e_pos: float = 0.0
extruded: float = 0.0

def __init__(self, config: ConfigHelper):
self.server = config.get_server()

self.sync_rate_seconds = config.getint("sync_rate", default=5, above=1)
self.last_sync_time = datetime.datetime.now()
self.extruded_lock = asyncio.Lock()
self.spoolman_url = f"{config.get('server').rstrip('/')}/api"

self.klippy_apis: APIComp = self.server.lookup_component("klippy_apis")
self.http_client: HttpClient = self.server.lookup_component(
"http_client"
)
self.database: MoonrakerDatabase = self.server.lookup_component(
"database"
)

self._register_notifications()
self._register_listeners()
self._register_endpoints()

def _register_notifications(self):
self.server.register_notification("spoolman:active_spool_set")

def _register_listeners(self):
self.server.register_event_handler(
"server:klippy_ready", self._handle_server_ready
)

def _register_endpoints(self):
self.server.register_endpoint(
"/spoolman/spool_id",
["GET", "POST"],
self._handle_spool_id_request,
)
self.server.register_endpoint(
"/spoolman/proxy",
["POST"],
self._proxy_spoolman_request,
)

async def component_init(self) -> None:
self.spool_id = await self.database.get_item(
DB_NAMESPACE, ACTIVE_SPOOL_KEY, None
)

async def _handle_server_ready(self):
self.server.register_event_handler(
"server:status_update", self._handle_status_update
)
result = await self.klippy_apis.subscribe_objects(
{"toolhead": ["position"]}
)
initial_e_pos = self._eposition_from_status(result)

logging.debug(f"Initial epos: {initial_e_pos}")

if initial_e_pos is not None:
self.highest_e_pos = initial_e_pos
else:
logging.error("Spoolman integration unable to subscribe to epos")
raise self.server.error("Unable to subscribe to e position")

def _eposition_from_status(self, status: Dict[str, Any]) -> Optional[float]:
position = status.get("toolhead", {}).get("position", [])
return position[3] if len(position) > 3 else None

async def _handle_status_update(self, status: Dict[str, Any]) -> None:
epos = self._eposition_from_status(status)
if epos and epos > self.highest_e_pos:
async with self.extruded_lock:
self.extruded += epos - self.highest_e_pos
self.highest_e_pos = epos

now = datetime.datetime.now()
difference = now - self.last_sync_time
if difference.total_seconds() > self.sync_rate_seconds:
self.last_sync_time = now
logging.debug("Sync period elapsed, tracking usage")
await self.track_filament_usage()

async def set_active_spool(self, spool_id: Optional[int]) -> None:
self.database.insert_item(DB_NAMESPACE, ACTIVE_SPOOL_KEY, spool_id)
self.spool_id = spool_id
await self.server.send_event(
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The send_event method returns a future that can be awaited for control flow, however I don't think we need to be concerned with it here as spoolman doesn't depend on the outcome. It should be enough to fire and forget.

Additionally, I think that perhaps we we need to call track_filament_usage before setting the new spool if the previous spool_id is not None, and reset self.extruded to 0 otherwise. This way we flush any residual filament usage and avoid including extrusion moves that isn't part of the new spool.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good points

"spool_manager:active_spool_set", {"spool_id": spool_id}
)
logging.info(f"Setting active spool to: {spool_id}")

def get_active_spool(self) -> Optional[int]:
return self.spool_id

async def track_filament_usage(self):
spool_id = self.get_active_spool()
if spool_id is None:
logging.debug("No active spool, skipping tracking")
return
async with self.extruded_lock:
if self.extruded > 0:
used_length = self.extruded

logging.debug(
f"Sending spool usage: "
f"ID: {spool_id}, "
f"Length: {used_length:.3f}mm, "
)

response = await self.http_client.request(
method="PUT",
url=f"{self.spoolman_url}/spool/{spool_id}/use",
body={
"use_length": used_length,
},
)
response.raise_for_status()
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should raise the exception here as it will spam the log if Spoolman is down. You could do something like the following:

if response.has_error():
    return

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like fully repressing the error since then you can't debug it. I've made it now so it prints the first error since it was last successful, sounds good?


self.extruded = 0

async def _handle_spool_id_request(self, web_request: WebRequest):
action = web_request.get_action()
if action == "GET":
return {"spool_id": self.get_active_spool()}
elif action == "POST":
spool_id = web_request.get_int("spool_id", None)
await self.set_active_spool(spool_id)
return True
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than return True I would recommend just returning the spool ID for both the GET and POST requests.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point


async def _proxy_spoolman_request(self, web_request: WebRequest):
method = web_request.get_str("method")
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may be better to name the argument request_method rather than method, so as not to confuse it with JSON-RPC's method parameter.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, good point

path = web_request.get_str("path")
query = web_request.get_str("query", None)
body = web_request.get("body", None)

if method not in {"GET", "POST", "PUT", "PATCH", "DELETE"}:
raise ServerError(f"Invalid HTTP method: {method}")
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use self.server.error instead of ServerError. Likewise it is probably not necessary to import ServerError for Type Checking since it isn't used in any annotations.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!


if body is not None and method == "GET":
raise ServerError("GET requests cannot have a body")

if len(path) < 4 or path[:4] != "/v1/":
raise ServerError(
"Invalid path, must start with the API version, e.g. /v1"
)

if query is not None:
query = f"?{query}"
else:
query = ""

full_url = f"{self.spoolman_url}{path}{query}"

logging.debug(f"Proxying {method} request to {full_url}")

response = await self.http_client.request(
method=method,
url=full_url,
body=body,
)
response.raise_for_status()

return response.json()


def load_component(config: ConfigHelper) -> SpoolManager:
return SpoolManager(config)