moonraker: refactor KlippyConnection
Move the KlippyConnection class into its own module. Refactor init to use loops rather than callbacks, this reduces complexity of tracking and cancelling callback handles. All Klippy state previously tracked by the Server is now in the KlippyConnection. This improves testing and makes the code less ambiguous, ie: the `server.make_request()` method is not as clear as `klippy.request()`. Signed-off-by: Eric Callahan <arksine.code@gmail.com>
This commit is contained in:
parent
737cf8a2cb
commit
b4ddffd5d1
|
@ -44,6 +44,7 @@ if TYPE_CHECKING:
|
|||
from moonraker import Server
|
||||
from eventloop import EventLoop
|
||||
from confighelper import ConfigHelper
|
||||
from klippy_connection import KlippyConnection as Klippy
|
||||
from components.file_manager.file_manager import FileManager
|
||||
import components.authorization
|
||||
MessageDelgate = Optional[tornado.httputil.HTTPMessageDelegate]
|
||||
|
@ -135,7 +136,8 @@ class InternalTransport(APITransport):
|
|||
# Request to Klippy
|
||||
method = api_def.jrpc_methods[0]
|
||||
action = ""
|
||||
cb = self.server.make_request
|
||||
klippy: Klippy = self.server.lookup_component("klippy_connection")
|
||||
cb = klippy.request
|
||||
self.callbacks[method] = (ep, action, cb)
|
||||
else:
|
||||
for method, action in \
|
||||
|
@ -620,7 +622,8 @@ class DynamicRequestHandler(AuthorizedRequestHandler):
|
|||
conn: Optional[WebSocket]
|
||||
) -> Any:
|
||||
assert isinstance(self.callback, str)
|
||||
return await self.server.make_request(
|
||||
klippy: Klippy = self.server.lookup_component("klippy_connection")
|
||||
return await klippy.request(
|
||||
WebRequest(self.callback, args, conn=conn,
|
||||
ip_addr=self.request.remote_ip,
|
||||
user=self.current_user))
|
||||
|
|
|
@ -22,6 +22,7 @@ from typing import (
|
|||
if TYPE_CHECKING:
|
||||
from confighelper import ConfigHelper
|
||||
from websockets import WebRequest
|
||||
from klippy_connection import KlippyConnection as Klippy
|
||||
Subscription = Dict[str, Optional[List[Any]]]
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
@ -39,6 +40,7 @@ SENTINEL = SentinelClass.get_instance()
|
|||
class KlippyAPI(Subscribable):
|
||||
def __init__(self, config: ConfigHelper) -> None:
|
||||
self.server = config.get_server()
|
||||
self.klippy: Klippy = self.server.lookup_component("klippy_connection")
|
||||
app_args = self.server.get_app_args()
|
||||
self.version = app_args.get('software_version')
|
||||
# Maintain a subscription for all moonraker requests, as
|
||||
|
@ -84,7 +86,7 @@ class KlippyAPI(Subscribable):
|
|||
default: Any = SENTINEL
|
||||
) -> Any:
|
||||
try:
|
||||
result = await self.server.make_request(
|
||||
result = await self.klippy.request(
|
||||
WebRequest(method, params, conn=self))
|
||||
except self.server.error:
|
||||
if isinstance(default, SentinelClass):
|
||||
|
@ -102,17 +104,26 @@ class KlippyAPI(Subscribable):
|
|||
return result
|
||||
|
||||
async def start_print(self, filename: str) -> str:
|
||||
# WARNING: Do not call this method from within the following
|
||||
# event handlers:
|
||||
# klippy_identified, klippy_started, klippy_ready, klippy_disconnect
|
||||
# Doing so will result in a deadlock
|
||||
# XXX - validate that file is on disk
|
||||
if filename[0] == '/':
|
||||
filename = filename[1:]
|
||||
# Escape existing double quotes in the file name
|
||||
filename = filename.replace("\"", "\\\"")
|
||||
script = f'SDCARD_PRINT_FILE FILENAME="{filename}"'
|
||||
await self.server.wait_connection_initialized()
|
||||
await self.klippy.wait_connected()
|
||||
return await self.run_gcode(script)
|
||||
|
||||
async def do_restart(self, gc: str) -> str:
|
||||
await self.server.wait_connection_initialized()
|
||||
# WARNING: Do not call this method from within the following
|
||||
# event handlers:
|
||||
# klippy_identified, klippy_started, klippy_ready, klippy_disconnect
|
||||
# Doing so will result in a deadlock
|
||||
# XXX - validate that file is on disk
|
||||
await self.klippy.wait_connected()
|
||||
try:
|
||||
result = await self.run_gcode(gc)
|
||||
except self.server.error as e:
|
||||
|
|
|
@ -31,6 +31,7 @@ from typing import (
|
|||
if TYPE_CHECKING:
|
||||
from app import APIDefinition
|
||||
from confighelper import ConfigHelper
|
||||
from klippy_connection import KlippyConnection as Klippy
|
||||
FlexCallback = Callable[[bytes], Optional[Coroutine]]
|
||||
RPCCallback = Callable[..., Coroutine]
|
||||
|
||||
|
@ -141,6 +142,7 @@ class MQTTClient(APITransport, Subscribable):
|
|||
def __init__(self, config: ConfigHelper) -> None:
|
||||
self.server = config.get_server()
|
||||
self.event_loop = self.server.get_event_loop()
|
||||
self.klippy: Klippy = self.server.lookup_component("klippy_connection")
|
||||
self.address: str = config.get('address')
|
||||
self.port: int = config.getint('port', 1883)
|
||||
user = config.gettemplate('username', None)
|
||||
|
@ -271,7 +273,7 @@ class MQTTClient(APITransport, Subscribable):
|
|||
if self.status_objs:
|
||||
args = {'objects': self.status_objs}
|
||||
try:
|
||||
await self.server.make_request(
|
||||
await self.klippy.request(
|
||||
WebRequest("objects/subscribe", args, conn=self))
|
||||
except self.server.error:
|
||||
pass
|
||||
|
@ -607,7 +609,7 @@ class MQTTClient(APITransport, Subscribable):
|
|||
def _generate_remote_callback(self, endpoint: str) -> RPCCallback:
|
||||
async def func(**kwargs) -> Any:
|
||||
self._check_timestamp(kwargs)
|
||||
result = await self.server.make_request(
|
||||
result = await self.klippy.request(
|
||||
WebRequest(endpoint, kwargs))
|
||||
return result
|
||||
return func
|
||||
|
|
|
@ -0,0 +1,543 @@
|
|||
|
||||
# KlippyConnection - manage unix socket connection to Klipper
|
||||
#
|
||||
# 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 time
|
||||
import logging
|
||||
import json
|
||||
import getpass
|
||||
import confighelper
|
||||
import asyncio
|
||||
from utils import ServerError
|
||||
|
||||
# Annotation imports
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Awaitable,
|
||||
Optional,
|
||||
Callable,
|
||||
Coroutine,
|
||||
Dict,
|
||||
List,
|
||||
)
|
||||
if TYPE_CHECKING:
|
||||
from app import MoonrakerApp
|
||||
from websockets import WebRequest, Subscribable
|
||||
from components.data_store import DataStore
|
||||
from components.klippy_apis import KlippyAPI
|
||||
from components.file_manager.file_manager import FileManager
|
||||
FlexCallback = Callable[..., Optional[Coroutine]]
|
||||
|
||||
INIT_TIME = .25
|
||||
LOG_ATTEMPT_INTERVAL = int(2. / INIT_TIME + .5)
|
||||
MAX_LOG_ATTEMPTS = 10 * LOG_ATTEMPT_INTERVAL
|
||||
UNIX_BUFFER_LIMIT = 2 * 1024 * 1024
|
||||
|
||||
class KlippyConnection:
|
||||
def __init__(self, config: confighelper.ConfigHelper) -> None:
|
||||
self.server = config.get_server()
|
||||
self.uds_address: str = config.get(
|
||||
'klippy_uds_address', "/tmp/klippy_uds")
|
||||
self.writer: Optional[asyncio.StreamWriter] = None
|
||||
self.connection_mutex: asyncio.Lock = asyncio.Lock()
|
||||
self.event_loop = self.server.get_event_loop()
|
||||
self.log_no_access = True
|
||||
# Connection State
|
||||
self.connection_task: Optional[asyncio.Task] = None
|
||||
self.closing: bool = False
|
||||
self._klippy_info: Dict[str, Any] = {}
|
||||
self.init_list: List[str] = []
|
||||
self.init_attempts: int = 0
|
||||
self._state: str = "disconnected"
|
||||
self.subscriptions: Dict[Subscribable, Dict[str, Any]] = {}
|
||||
# Setup remote methods accessable to Klippy. Note that all
|
||||
# registered remote methods should be of the notification type,
|
||||
# they do not return a response to Klippy after execution
|
||||
self.pending_requests: Dict[int, KlippyRequest] = {}
|
||||
self.remote_methods: Dict[str, FlexCallback] = {}
|
||||
self.klippy_reg_methods: List[str] = []
|
||||
self.register_remote_method(
|
||||
'process_gcode_response', self._process_gcode_response,
|
||||
need_klippy_reg=False)
|
||||
self.register_remote_method(
|
||||
'process_status_update', self._process_status_update,
|
||||
need_klippy_reg=False)
|
||||
self.server.register_component("klippy_connection", self)
|
||||
|
||||
@property
|
||||
def klippy_apis(self) -> KlippyAPI:
|
||||
return self.server.lookup_component("klippy_apis")
|
||||
|
||||
@property
|
||||
def state(self) -> str:
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def klippy_info(self) -> Dict[str, Any]:
|
||||
return self._klippy_info
|
||||
|
||||
async def wait_connected(self) -> bool:
|
||||
if (
|
||||
self.connection_task is None or
|
||||
self.connection_task.done()
|
||||
):
|
||||
return self.is_connected()
|
||||
try:
|
||||
await self.connection_task
|
||||
except Exception:
|
||||
pass
|
||||
return self.is_connected()
|
||||
|
||||
async def _read_stream(self, reader: asyncio.StreamReader) -> None:
|
||||
errors_remaining: int = 10
|
||||
while not reader.at_eof():
|
||||
try:
|
||||
data = await reader.readuntil(b'\x03')
|
||||
except (ConnectionError, asyncio.IncompleteReadError):
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
logging.exception("Klippy Stream Read Cancelled")
|
||||
raise
|
||||
except Exception:
|
||||
logging.exception("Klippy Stream Read Error")
|
||||
errors_remaining -= 1
|
||||
if not errors_remaining or not self.is_connected():
|
||||
break
|
||||
continue
|
||||
errors_remaining = 10
|
||||
try:
|
||||
decoded_cmd = json.loads(data[:-1])
|
||||
self._process_command(decoded_cmd)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
f"Error processing Klippy Host Response: {data.decode()}")
|
||||
if not self.closing:
|
||||
logging.debug("Klippy Disconnection From _read_stream()")
|
||||
await self.close()
|
||||
|
||||
async def _write_request(self, request: KlippyRequest) -> None:
|
||||
if self.writer is None or self.closing:
|
||||
self.pending_requests.pop(request.id, None)
|
||||
request.notify(ServerError("Klippy Host not connected", 503))
|
||||
return
|
||||
data = json.dumps(request.to_dict()).encode() + b"\x03"
|
||||
try:
|
||||
self.writer.write(data)
|
||||
await self.writer.drain()
|
||||
except asyncio.CancelledError:
|
||||
self.pending_requests.pop(request.id, None)
|
||||
request.notify(ServerError("Klippy Write Request Cancelled", 503))
|
||||
raise
|
||||
except Exception:
|
||||
self.pending_requests.pop(request.id, None)
|
||||
request.notify(ServerError("Klippy Write Request Error", 503))
|
||||
if not self.closing:
|
||||
logging.debug("Klippy Disconnection From _write_request()")
|
||||
await self.close()
|
||||
|
||||
def register_remote_method(self,
|
||||
method_name: str,
|
||||
cb: FlexCallback,
|
||||
need_klippy_reg: bool = True
|
||||
) -> None:
|
||||
if method_name in self.remote_methods:
|
||||
raise self.server.error(
|
||||
f"Remote method ({method_name}) already registered")
|
||||
if self.server.is_running():
|
||||
raise self.server.error(
|
||||
f"Failed to register remote method {method_name}, "
|
||||
"methods must be registered during initialization")
|
||||
self.remote_methods[method_name] = cb
|
||||
if need_klippy_reg:
|
||||
# These methods need to be registered with Klippy
|
||||
self.klippy_reg_methods.append(method_name)
|
||||
|
||||
def connect(self) -> Awaitable[bool]:
|
||||
if (
|
||||
self.is_connected() or
|
||||
not self.server.is_running() or
|
||||
(self.connection_task is not None and
|
||||
not self.connection_task.done())
|
||||
):
|
||||
# already connecting
|
||||
fut = self.event_loop.create_future()
|
||||
fut.set_result(self.is_connected())
|
||||
return fut
|
||||
self.connection_task = self.event_loop.create_task(self._do_connect())
|
||||
return self.connection_task
|
||||
|
||||
async def _do_connect(self) -> bool:
|
||||
async with self.connection_mutex:
|
||||
while self.writer is None:
|
||||
await asyncio.sleep(INIT_TIME)
|
||||
if self.closing or not self.server.is_running():
|
||||
return False
|
||||
if not os.path.exists(self.uds_address):
|
||||
continue
|
||||
if not os.access(self.uds_address, os.R_OK | os.W_OK):
|
||||
if self.log_no_access:
|
||||
user = getpass.getuser()
|
||||
logging.info(
|
||||
f"Cannot connect to Klippy, Linux user '{user}' "
|
||||
"lacks permission to open Unix Domain Socket: "
|
||||
f"{self.uds_address}")
|
||||
self.log_no_access = False
|
||||
continue
|
||||
self.log_no_access = True
|
||||
try:
|
||||
reader, writer = await asyncio.open_unix_connection(
|
||||
self.uds_address, limit=UNIX_BUFFER_LIMIT)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
continue
|
||||
logging.info("Klippy Connection Established")
|
||||
self.writer = writer
|
||||
self.event_loop.create_task(self._read_stream(reader))
|
||||
return await self._init_klippy_connection()
|
||||
|
||||
async def _init_klippy_connection(self) -> bool:
|
||||
self.init_list = []
|
||||
self.init_attempts = 0
|
||||
self._state = "initializing"
|
||||
webhooks_err_logged = False
|
||||
gcout_err_logged = False
|
||||
while self.server.is_running():
|
||||
await asyncio.sleep(INIT_TIME)
|
||||
# Subscribe to "webhooks"
|
||||
# Register "webhooks" subscription
|
||||
if "webhooks_sub" not in self.init_list:
|
||||
try:
|
||||
await self.klippy_apis.subscribe_objects(
|
||||
{'webhooks': None})
|
||||
except ServerError as e:
|
||||
if not webhooks_err_logged:
|
||||
webhooks_err_logged = True
|
||||
logging.info(
|
||||
f"{e}\nUnable to subscribe to webhooks object")
|
||||
else:
|
||||
logging.info("Webhooks Subscribed")
|
||||
self.init_list.append("webhooks_sub")
|
||||
# Subscribe to Gcode Output
|
||||
if "gcode_output_sub" not in self.init_list:
|
||||
try:
|
||||
await self.klippy_apis.subscribe_gcode_output()
|
||||
except ServerError as e:
|
||||
if not gcout_err_logged:
|
||||
gcout_err_logged = True
|
||||
logging.info(
|
||||
f"{e}\nUnable to register gcode output "
|
||||
"subscription")
|
||||
else:
|
||||
logging.info("GCode Output Subscribed")
|
||||
self.init_list.append("gcode_output_sub")
|
||||
if "startup_complete" not in self.init_list:
|
||||
await self._check_ready()
|
||||
if len(self.init_list) == 4:
|
||||
logging.debug("Klippy Connection Initialized")
|
||||
return True
|
||||
elif not self.is_connected():
|
||||
break
|
||||
else:
|
||||
self.init_attempts += 1
|
||||
logging.debug("Klippy Connection Failed to Init")
|
||||
return False
|
||||
|
||||
async def _request_endpoints(self) -> None:
|
||||
result = await self.klippy_apis.list_endpoints(default=None)
|
||||
if result is None:
|
||||
return
|
||||
endpoints = result.get('endpoints', [])
|
||||
app: MoonrakerApp = self.server.lookup_component("application")
|
||||
for ep in endpoints:
|
||||
app.register_remote_handler(ep)
|
||||
|
||||
async def _check_ready(self) -> None:
|
||||
send_id = "identified" not in self.init_list
|
||||
result: Dict[str, Any]
|
||||
try:
|
||||
result = await self.klippy_apis.get_klippy_info(send_id)
|
||||
except ServerError as e:
|
||||
if self.init_attempts % LOG_ATTEMPT_INTERVAL == 0 and \
|
||||
self.init_attempts <= MAX_LOG_ATTEMPTS:
|
||||
logging.info(
|
||||
f"{e}\nKlippy info request error. This indicates that\n"
|
||||
f"Klippy may have experienced an error during startup.\n"
|
||||
f"Please check klippy.log for more information")
|
||||
return
|
||||
self._klippy_info = dict(result)
|
||||
self._state = result.get('state', "unknown")
|
||||
if send_id:
|
||||
self.init_list.append("identified")
|
||||
await self.server.send_event("server:klippy_identified")
|
||||
if self._state != "startup":
|
||||
self.init_list.append('startup_complete')
|
||||
await self._request_endpoints()
|
||||
await self.server.send_event("server:klippy_started",
|
||||
self._state)
|
||||
if self._state != "ready":
|
||||
msg = result.get('state_message', "Klippy Not Ready")
|
||||
logging.info("\n" + msg)
|
||||
else:
|
||||
await self._verify_klippy_requirements()
|
||||
# register methods with klippy
|
||||
for method in self.klippy_reg_methods:
|
||||
try:
|
||||
await self.klippy_apis.register_method(method)
|
||||
except ServerError:
|
||||
logging.exception(
|
||||
f"Unable to register method '{method}'")
|
||||
logging.info("Klippy ready")
|
||||
await self.server.send_event("server:klippy_ready")
|
||||
|
||||
async def _verify_klippy_requirements(self) -> None:
|
||||
result = await self.klippy_apis.get_object_list(default=None)
|
||||
if result is None:
|
||||
logging.info(
|
||||
f"Unable to retrieve Klipper Object List")
|
||||
return
|
||||
req_objs = set(["virtual_sdcard", "display_status", "pause_resume"])
|
||||
missing_objs = req_objs - set(result)
|
||||
if missing_objs:
|
||||
err_str = ", ".join([f"[{o}]" for o in missing_objs])
|
||||
logging.info(
|
||||
f"\nWarning, unable to detect the following printer "
|
||||
f"objects:\n{err_str}\nPlease add the the above sections "
|
||||
f"to printer.cfg for full Moonraker functionality.")
|
||||
if "virtual_sdcard" not in missing_objs:
|
||||
# Update the gcode path
|
||||
query_res = await self.klippy_apis.query_objects(
|
||||
{'configfile': None}, default=None)
|
||||
if query_res is None:
|
||||
logging.info(f"Unable to set SD Card path")
|
||||
else:
|
||||
config = query_res.get('configfile', {}).get('config', {})
|
||||
vsd_config = config.get('virtual_sdcard', {})
|
||||
vsd_path = vsd_config.get('path', None)
|
||||
if vsd_path is not None:
|
||||
file_manager: FileManager = self.server.lookup_component(
|
||||
'file_manager')
|
||||
file_manager.register_directory('gcodes', vsd_path,
|
||||
full_access=True)
|
||||
else:
|
||||
logging.info(
|
||||
"Configuration for [virtual_sdcard] not found,"
|
||||
" unable to set SD Card path")
|
||||
|
||||
def _process_command(self, cmd: Dict[str, Any]) -> None:
|
||||
method = cmd.get('method', None)
|
||||
if method is not None:
|
||||
# This is a remote method called from klippy
|
||||
if method in self.remote_methods:
|
||||
params = cmd.get('params', {})
|
||||
self.event_loop.register_callback(
|
||||
self._execute_method, method, **params)
|
||||
else:
|
||||
logging.info(f"Unknown method received: {method}")
|
||||
return
|
||||
# This is a response to a request, process
|
||||
req_id = cmd.get('id', None)
|
||||
request: Optional[KlippyRequest]
|
||||
request = self.pending_requests.pop(req_id, None)
|
||||
if request is None:
|
||||
logging.info(
|
||||
f"No request matching request ID: {req_id}, "
|
||||
f"response: {cmd}")
|
||||
return
|
||||
if 'result' in cmd:
|
||||
result = cmd['result']
|
||||
if not result:
|
||||
result = "ok"
|
||||
else:
|
||||
err = cmd.get('error', "Malformed Klippy Response")
|
||||
result = ServerError(err, 400)
|
||||
request.notify(result)
|
||||
|
||||
async def _execute_method(self, method_name: str, **kwargs) -> None:
|
||||
try:
|
||||
ret = self.remote_methods[method_name](**kwargs)
|
||||
if ret is not None:
|
||||
await ret
|
||||
except Exception:
|
||||
logging.exception(f"Error running remote method: {method_name}")
|
||||
|
||||
def _process_gcode_response(self, response: str) -> None:
|
||||
self.server.send_event("server:gcode_response", response)
|
||||
|
||||
def _process_status_update(self,
|
||||
eventtime: float,
|
||||
status: Dict[str, Any]
|
||||
) -> None:
|
||||
if 'webhooks' in status:
|
||||
# XXX - process other states (startup, ready, error, etc)?
|
||||
state: Optional[str] = status['webhooks'].get('state', None)
|
||||
if state is not None:
|
||||
if state == "shutdown":
|
||||
logging.info("Klippy has shutdown")
|
||||
self.server.send_event("server:klippy_shutdown")
|
||||
self._state = state
|
||||
for conn, sub in self.subscriptions.items():
|
||||
conn_status: Dict[str, Any] = {}
|
||||
for name, fields in sub.items():
|
||||
if name in status:
|
||||
val: Dict[str, Any] = dict(status[name])
|
||||
if fields is not None:
|
||||
val = {k: v for k, v in val.items() if k in fields}
|
||||
if val:
|
||||
conn_status[name] = val
|
||||
conn.send_status(conn_status, eventtime)
|
||||
|
||||
async def request(self, web_request: WebRequest) -> Any:
|
||||
if not self.is_connected():
|
||||
raise ServerError("Klippy Host not connected", 503)
|
||||
rpc_method = web_request.get_endpoint()
|
||||
if rpc_method == "objects/subscribe":
|
||||
return await self._request_subscripton(web_request)
|
||||
else:
|
||||
if rpc_method == "gcode/script":
|
||||
script = web_request.get_str('script', "")
|
||||
data_store: DataStore
|
||||
data_store = self.server.lookup_component('data_store')
|
||||
data_store.store_gcode_command(script)
|
||||
return await self._request_standard(web_request)
|
||||
|
||||
async def _request_subscripton(self,
|
||||
web_request: WebRequest
|
||||
) -> Dict[str, Any]:
|
||||
args = web_request.get_args()
|
||||
conn = web_request.get_connection()
|
||||
|
||||
# Build the subscription request from a superset of all client
|
||||
# subscriptions
|
||||
sub = args.get('objects', {})
|
||||
if conn is None:
|
||||
raise self.server.error(
|
||||
"No connection associated with subscription request")
|
||||
self.subscriptions[conn] = sub
|
||||
all_subs: Dict[str, Any] = {}
|
||||
# request superset of all client subscriptions
|
||||
for sub in self.subscriptions.values():
|
||||
for obj, items in sub.items():
|
||||
if obj in all_subs:
|
||||
pi = all_subs[obj]
|
||||
if items is None or pi is None:
|
||||
all_subs[obj] = None
|
||||
else:
|
||||
uitems = list(set(pi) | set(items))
|
||||
all_subs[obj] = uitems
|
||||
else:
|
||||
all_subs[obj] = items
|
||||
args['objects'] = all_subs
|
||||
args['response_template'] = {'method': "process_status_update"}
|
||||
|
||||
result = await self._request_standard(web_request)
|
||||
|
||||
# prune the status response
|
||||
pruned_status = {}
|
||||
all_status = result['status']
|
||||
sub = self.subscriptions.get(conn, {})
|
||||
for obj, fields in all_status.items():
|
||||
if obj in sub:
|
||||
valid_fields = sub[obj]
|
||||
if valid_fields is None:
|
||||
pruned_status[obj] = fields
|
||||
else:
|
||||
pruned_status[obj] = {k: v for k, v in fields.items()
|
||||
if k in valid_fields}
|
||||
result['status'] = pruned_status
|
||||
return result
|
||||
|
||||
async def _request_standard(self, web_request: WebRequest) -> Any:
|
||||
rpc_method = web_request.get_endpoint()
|
||||
args = web_request.get_args()
|
||||
# Create a base klippy request
|
||||
base_request = KlippyRequest(rpc_method, args)
|
||||
self.pending_requests[base_request.id] = base_request
|
||||
self.event_loop.register_callback(self._write_request, base_request)
|
||||
return await base_request.wait()
|
||||
|
||||
def remove_subscription(self, conn: Subscribable) -> None:
|
||||
self.subscriptions.pop(conn, None)
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
return self.writer is not None and not self.closing
|
||||
|
||||
async def _on_connection_closed(self) -> None:
|
||||
self.init_list = []
|
||||
self._state = "disconnected"
|
||||
for request in self.pending_requests.values():
|
||||
request.notify(ServerError("Klippy Disconnected", 503))
|
||||
self.pending_requests = {}
|
||||
self.subscriptions = {}
|
||||
logging.info("Klippy Connection Removed")
|
||||
await self.server.send_event("server:klippy_disconnect")
|
||||
if self.server.is_running():
|
||||
# Reconnect if server is running
|
||||
loop = self.event_loop
|
||||
self.connection_task = loop.create_task(self._do_connect())
|
||||
|
||||
async def close(self, wait_closed: bool = False) -> None:
|
||||
if self.closing:
|
||||
if wait_closed:
|
||||
await self.connection_mutex.acquire()
|
||||
self.connection_mutex.release()
|
||||
return
|
||||
self.closing = True
|
||||
if (
|
||||
self.connection_task is not None and
|
||||
not self.connection_task.done()
|
||||
):
|
||||
self.connection_task.cancel()
|
||||
async with self.connection_mutex:
|
||||
if self.writer is not None:
|
||||
try:
|
||||
self.writer.close()
|
||||
await self.writer.wait_closed()
|
||||
except Exception:
|
||||
logging.exception("Error closing Klippy Unix Socket")
|
||||
self.writer = None
|
||||
await self._on_connection_closed()
|
||||
self.closing = False
|
||||
|
||||
# Basic KlippyRequest class, easily converted to dict for json encoding
|
||||
class KlippyRequest:
|
||||
def __init__(self, rpc_method: str, params: Dict[str, Any]) -> None:
|
||||
self.id = id(self)
|
||||
self.rpc_method = rpc_method
|
||||
self.params = params
|
||||
self._event = asyncio.Event()
|
||||
self.response: Any = None
|
||||
|
||||
async def wait(self) -> Any:
|
||||
# Log pending requests every 60 seconds
|
||||
start_time = time.time()
|
||||
while True:
|
||||
try:
|
||||
await asyncio.wait_for(self._event.wait(), 60.)
|
||||
except asyncio.TimeoutError:
|
||||
pending_time = time.time() - start_time
|
||||
logging.info(
|
||||
f"Request '{self.rpc_method}' pending: "
|
||||
f"{pending_time:.2f} seconds")
|
||||
self._event.clear()
|
||||
continue
|
||||
break
|
||||
if isinstance(self.response, ServerError):
|
||||
raise self.response
|
||||
return self.response
|
||||
|
||||
def notify(self, response: Any) -> None:
|
||||
if self._event.is_set():
|
||||
return
|
||||
self.response = response
|
||||
self._event.set()
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {'id': self.id, 'method': self.rpc_method,
|
||||
'params': self.params}
|
|
@ -22,8 +22,6 @@ import io
|
|||
import time
|
||||
import socket
|
||||
import logging
|
||||
import json
|
||||
import getpass
|
||||
import signal
|
||||
import confighelper
|
||||
import utils
|
||||
|
@ -31,6 +29,7 @@ import asyncio
|
|||
from tornado.httpclient import AsyncHTTPClient
|
||||
from eventloop import EventLoop
|
||||
from app import MoonrakerApp
|
||||
from klippy_connection import KlippyConnection
|
||||
from utils import ServerError, SentinelClass
|
||||
|
||||
# Annotation imports
|
||||
|
@ -46,18 +45,11 @@ from typing import (
|
|||
TypeVar,
|
||||
)
|
||||
if TYPE_CHECKING:
|
||||
from websockets import WebRequest, Subscribable, WebsocketManager
|
||||
from components.data_store import DataStore
|
||||
from components.klippy_apis import KlippyAPI
|
||||
from websockets import WebRequest, WebsocketManager
|
||||
from components.file_manager.file_manager import FileManager
|
||||
FlexCallback = Callable[..., Optional[Coroutine]]
|
||||
_T = TypeVar("_T")
|
||||
|
||||
INIT_TIME = .25
|
||||
LOG_ATTEMPT_INTERVAL = int(2. / INIT_TIME + .5)
|
||||
MAX_LOG_ATTEMPTS = 10 * LOG_ATTEMPT_INTERVAL
|
||||
UNIX_BUFFER_LIMIT = 2 * 1024 * 1024
|
||||
|
||||
CORE_COMPONENTS = [
|
||||
'dbus_manager', 'database', 'file_manager', 'klippy_apis',
|
||||
'machine', 'data_store', 'shell_command', 'proc_stats',
|
||||
|
@ -86,6 +78,7 @@ class Server:
|
|||
self.port: int = config.getint('port', 7125)
|
||||
self.ssl_port: int = config.getint('ssl_port', 7130)
|
||||
self.exit_reason: str = ""
|
||||
self.server_running: bool = False
|
||||
|
||||
# Configure Debug Logging
|
||||
self.debug = config.getboolean('enable_debug_logging', False)
|
||||
|
@ -96,26 +89,12 @@ class Server:
|
|||
|
||||
# Event initialization
|
||||
self.events: Dict[str, List[FlexCallback]] = {}
|
||||
|
||||
# Klippy Connection Handling
|
||||
self.klippy_address: str = config.get(
|
||||
'klippy_uds_address', "/tmp/klippy_uds")
|
||||
self.klippy_connection = KlippyConnection(
|
||||
self.process_command, self.on_connection_closed, event_loop)
|
||||
self.klippy_info: Dict[str, Any] = {}
|
||||
self.init_list: List[str] = []
|
||||
self.init_handle: Optional[asyncio.Handle] = None
|
||||
self.init_attempts: int = 0
|
||||
self.klippy_state: str = "disconnected"
|
||||
self.klippy_disconnect_evt: Optional[asyncio.Event] = None
|
||||
self.connection_init_lock: asyncio.Lock = asyncio.Lock()
|
||||
self.components: Dict[str, Any] = {}
|
||||
self.subscriptions: Dict[Subscribable, Dict[str, Any]] = {}
|
||||
self.failed_components: List[str] = []
|
||||
self.warnings: List[str] = []
|
||||
self.klippy_connection = KlippyConnection(config)
|
||||
|
||||
# Tornado Application/Server
|
||||
self.server_running: bool = False
|
||||
self.moonraker_app = app = MoonrakerApp(config)
|
||||
self.register_endpoint = app.register_local_handler
|
||||
self.register_static_file_handler = app.register_static_file_handler
|
||||
|
@ -135,30 +114,12 @@ class Server:
|
|||
"/server/config", ['GET'], self._handle_config_request)
|
||||
self.register_endpoint(
|
||||
"/server/restart", ['POST'], self._handle_server_restart)
|
||||
|
||||
self.register_notification("server:klippy_ready")
|
||||
self.register_notification("server:klippy_shutdown")
|
||||
self.register_notification("server:klippy_disconnect",
|
||||
"klippy_disconnected")
|
||||
self.register_notification("server:gcode_response")
|
||||
|
||||
# Setup remote methods accessable to Klippy. Note that all
|
||||
# registered remote methods should be of the notification type,
|
||||
# they do not return a response to Klippy after execution
|
||||
self.pending_requests: Dict[int, BaseRequest] = {}
|
||||
self.remote_methods: Dict[str, FlexCallback] = {}
|
||||
self.klippy_reg_methods: List[str] = []
|
||||
self.register_remote_method(
|
||||
'process_gcode_response', self._process_gcode_response,
|
||||
need_klippy_reg=False)
|
||||
self.register_remote_method(
|
||||
'process_status_update', self._process_status_update,
|
||||
need_klippy_reg=False)
|
||||
|
||||
@property
|
||||
def klippy_apis(self) -> KlippyAPI:
|
||||
return self.components['klippy_apis']
|
||||
|
||||
def get_app_args(self) -> Dict[str, Any]:
|
||||
return dict(self.app_args)
|
||||
|
||||
|
@ -219,11 +180,7 @@ class Server:
|
|||
self.moonraker_app.listen(self.host, self.port, self.ssl_port)
|
||||
self.server_running = True
|
||||
if connect_to_klippy:
|
||||
await self.connect_klippy()
|
||||
|
||||
async def wait_connection_initialized(self) -> None:
|
||||
async with self.connection_init_lock:
|
||||
return
|
||||
self.klippy_connection.connect()
|
||||
|
||||
def add_log_rollover_item(self, name: str, item: str,
|
||||
log: bool = True) -> None:
|
||||
|
@ -347,20 +304,9 @@ class Server:
|
|||
|
||||
def register_remote_method(self,
|
||||
method_name: str,
|
||||
cb: FlexCallback,
|
||||
need_klippy_reg: bool = True
|
||||
cb: FlexCallback
|
||||
) -> None:
|
||||
if method_name in self.remote_methods:
|
||||
raise self.error(
|
||||
f"Remote method ({method_name}) already registered")
|
||||
if self.server_running:
|
||||
raise self.error(
|
||||
f"Failed to register remote method {method_name}, "
|
||||
"methods must be registered during initialization")
|
||||
self.remote_methods[method_name] = cb
|
||||
if need_klippy_reg:
|
||||
# These methods need to be registered with Klippy
|
||||
self.klippy_reg_methods.append(method_name)
|
||||
self.klippy_connection.register_remote_method(method_name, cb)
|
||||
|
||||
def get_host_info(self) -> Dict[str, Any]:
|
||||
return {
|
||||
|
@ -371,292 +317,10 @@ class Server:
|
|||
}
|
||||
|
||||
def get_klippy_info(self) -> Dict[str, Any]:
|
||||
return dict(self.klippy_info)
|
||||
return self.klippy_connection.klippy_info
|
||||
|
||||
def get_klippy_state(self) -> str:
|
||||
return self.klippy_state
|
||||
|
||||
# ***** Klippy Connection *****
|
||||
async def connect_klippy(self) -> None:
|
||||
if not self.server_running:
|
||||
return
|
||||
if self.klippy_connection.is_connected():
|
||||
return
|
||||
ret = await self.klippy_connection.connect(self.klippy_address)
|
||||
if not ret:
|
||||
self.event_loop.delay_callback(.25, self.connect_klippy)
|
||||
return
|
||||
self.init_handle = self.event_loop.delay_callback(
|
||||
0.01, self._init_klippy_connection)
|
||||
|
||||
def process_command(self, cmd: Dict[str, Any]) -> None:
|
||||
method = cmd.get('method', None)
|
||||
if method is not None:
|
||||
# This is a remote method called from klippy
|
||||
if method in self.remote_methods:
|
||||
params = cmd.get('params', {})
|
||||
self.event_loop.register_callback(
|
||||
self._execute_method, method, **params)
|
||||
else:
|
||||
logging.info(f"Unknown method received: {method}")
|
||||
return
|
||||
# This is a response to a request, process
|
||||
req_id = cmd.get('id', None)
|
||||
request: Optional[BaseRequest]
|
||||
request = self.pending_requests.pop(req_id, None)
|
||||
if request is None:
|
||||
logging.info(
|
||||
f"No request matching request ID: {req_id}, "
|
||||
f"response: {cmd}")
|
||||
return
|
||||
if 'result' in cmd:
|
||||
result = cmd['result']
|
||||
if not result:
|
||||
result = "ok"
|
||||
else:
|
||||
err = cmd.get('error', "Malformed Klippy Response")
|
||||
result = ServerError(err, 400)
|
||||
request.notify(result)
|
||||
|
||||
async def _execute_method(self, method_name: str, **kwargs) -> None:
|
||||
try:
|
||||
ret = self.remote_methods[method_name](**kwargs)
|
||||
if ret is not None:
|
||||
await ret
|
||||
except Exception:
|
||||
logging.exception(f"Error running remote method: {method_name}")
|
||||
|
||||
def on_connection_closed(self) -> None:
|
||||
self.init_list = []
|
||||
self.klippy_state = "disconnected"
|
||||
for request in self.pending_requests.values():
|
||||
request.notify(ServerError("Klippy Disconnected", 503))
|
||||
self.pending_requests = {}
|
||||
self.subscriptions = {}
|
||||
logging.info("Klippy Connection Removed")
|
||||
self.send_event("server:klippy_disconnect")
|
||||
if self.init_handle is not None:
|
||||
self.init_handle.cancel()
|
||||
self.init_handle = None
|
||||
if self.server_running:
|
||||
self.event_loop.delay_callback(.25, self.connect_klippy)
|
||||
if self.klippy_disconnect_evt is not None:
|
||||
self.klippy_disconnect_evt.set()
|
||||
|
||||
async def _init_klippy_connection(self) -> None:
|
||||
if not self.server_running:
|
||||
return
|
||||
async with self.connection_init_lock:
|
||||
await self._check_ready()
|
||||
await self._request_endpoints()
|
||||
# Subscribe to "webhooks"
|
||||
# Register "webhooks" subscription
|
||||
if "webhooks_sub" not in self.init_list:
|
||||
try:
|
||||
await self.klippy_apis.subscribe_objects(
|
||||
{'webhooks': None})
|
||||
except ServerError as e:
|
||||
logging.info(
|
||||
f"{e}\nUnable to subscribe to webhooks object")
|
||||
else:
|
||||
logging.info("Webhooks Subscribed")
|
||||
self.init_list.append("webhooks_sub")
|
||||
# Subscribe to Gcode Output
|
||||
if "gcode_output_sub" not in self.init_list:
|
||||
try:
|
||||
await self.klippy_apis.subscribe_gcode_output()
|
||||
except ServerError as e:
|
||||
logging.info(
|
||||
f"{e}\nUnable to register gcode output subscription")
|
||||
else:
|
||||
logging.info("GCode Output Subscribed")
|
||||
self.init_list.append("gcode_output_sub")
|
||||
if (
|
||||
"startup_complete" in self.init_list or
|
||||
not self.klippy_connection.is_connected()
|
||||
):
|
||||
# Either Klippy is ready or the connection dropped
|
||||
# during initialization. Exit initialization
|
||||
self.init_attempts = 0
|
||||
self.init_handle = None
|
||||
else:
|
||||
self.init_attempts += 1
|
||||
self.init_handle = self.event_loop.delay_callback(
|
||||
INIT_TIME, self._init_klippy_connection)
|
||||
|
||||
async def _request_endpoints(self) -> None:
|
||||
result = await self.klippy_apis.list_endpoints(default=None)
|
||||
if result is None:
|
||||
return
|
||||
endpoints = result.get('endpoints', [])
|
||||
for ep in endpoints:
|
||||
self.moonraker_app.register_remote_handler(ep)
|
||||
|
||||
async def _check_ready(self) -> None:
|
||||
send_id = "identified" not in self.init_list
|
||||
result: Dict[str, Any]
|
||||
try:
|
||||
result = await self.klippy_apis.get_klippy_info(send_id)
|
||||
except ServerError as e:
|
||||
if self.init_attempts % LOG_ATTEMPT_INTERVAL == 0 and \
|
||||
self.init_attempts <= MAX_LOG_ATTEMPTS:
|
||||
logging.info(
|
||||
f"{e}\nKlippy info request error. This indicates that\n"
|
||||
f"Klippy may have experienced an error during startup.\n"
|
||||
f"Please check klippy.log for more information")
|
||||
return
|
||||
self.klippy_info = dict(result)
|
||||
self.klippy_state = result.get('state', "unknown")
|
||||
if send_id:
|
||||
self.init_list.append("identified")
|
||||
await self.send_event("server:klippy_identified")
|
||||
if self.klippy_state != "startup":
|
||||
self.init_list.append('startup_complete')
|
||||
await self.send_event("server:klippy_started", self.klippy_state)
|
||||
if self.klippy_state != "ready":
|
||||
msg = result.get('state_message', "Klippy Not Ready")
|
||||
logging.info("\n" + msg)
|
||||
else:
|
||||
await self._verify_klippy_requirements()
|
||||
# register methods with klippy
|
||||
for method in self.klippy_reg_methods:
|
||||
try:
|
||||
await self.klippy_apis.register_method(method)
|
||||
except ServerError:
|
||||
logging.exception(
|
||||
f"Unable to register method '{method}'")
|
||||
logging.info("Klippy ready")
|
||||
await self.send_event("server:klippy_ready")
|
||||
|
||||
async def _verify_klippy_requirements(self) -> None:
|
||||
result = await self.klippy_apis.get_object_list(default=None)
|
||||
if result is None:
|
||||
logging.info(
|
||||
f"Unable to retrieve Klipper Object List")
|
||||
return
|
||||
req_objs = set(["virtual_sdcard", "display_status", "pause_resume"])
|
||||
missing_objs = req_objs - set(result)
|
||||
if missing_objs:
|
||||
err_str = ", ".join([f"[{o}]" for o in missing_objs])
|
||||
logging.info(
|
||||
f"\nWarning, unable to detect the following printer "
|
||||
f"objects:\n{err_str}\nPlease add the the above sections "
|
||||
f"to printer.cfg for full Moonraker functionality.")
|
||||
if "virtual_sdcard" not in missing_objs:
|
||||
# Update the gcode path
|
||||
query_res = await self.klippy_apis.query_objects(
|
||||
{'configfile': None}, default=None)
|
||||
if query_res is None:
|
||||
logging.info(f"Unable to set SD Card path")
|
||||
else:
|
||||
config = query_res.get('configfile', {}).get('config', {})
|
||||
vsd_config = config.get('virtual_sdcard', {})
|
||||
vsd_path = vsd_config.get('path', None)
|
||||
if vsd_path is not None:
|
||||
file_manager: FileManager = self.lookup_component(
|
||||
'file_manager')
|
||||
file_manager.register_directory('gcodes', vsd_path,
|
||||
full_access=True)
|
||||
else:
|
||||
logging.info(
|
||||
"Configuration for [virtual_sdcard] not found,"
|
||||
" unable to set SD Card path")
|
||||
|
||||
def _process_gcode_response(self, response: str) -> None:
|
||||
self.send_event("server:gcode_response", response)
|
||||
|
||||
def _process_status_update(self,
|
||||
eventtime: float,
|
||||
status: Dict[str, Any]
|
||||
) -> None:
|
||||
if 'webhooks' in status:
|
||||
# XXX - process other states (startup, ready, error, etc)?
|
||||
state: Optional[str] = status['webhooks'].get('state', None)
|
||||
if state is not None:
|
||||
if state == "shutdown":
|
||||
logging.info("Klippy has shutdown")
|
||||
self.send_event("server:klippy_shutdown")
|
||||
self.klippy_state = state
|
||||
for conn, sub in self.subscriptions.items():
|
||||
conn_status: Dict[str, Any] = {}
|
||||
for name, fields in sub.items():
|
||||
if name in status:
|
||||
val: Dict[str, Any] = dict(status[name])
|
||||
if fields is not None:
|
||||
val = {k: v for k, v in val.items() if k in fields}
|
||||
if val:
|
||||
conn_status[name] = val
|
||||
conn.send_status(conn_status, eventtime)
|
||||
|
||||
async def make_request(self, web_request: WebRequest) -> Any:
|
||||
rpc_method = web_request.get_endpoint()
|
||||
if rpc_method == "objects/subscribe":
|
||||
return await self._request_subscripton(web_request)
|
||||
else:
|
||||
if rpc_method == "gcode/script":
|
||||
script = web_request.get_str('script', "")
|
||||
data_store: DataStore = self.lookup_component('data_store')
|
||||
data_store.store_gcode_command(script)
|
||||
return await self._request_standard(web_request)
|
||||
|
||||
async def _request_subscripton(self,
|
||||
web_request: WebRequest
|
||||
) -> Dict[str, Any]:
|
||||
args = web_request.get_args()
|
||||
conn = web_request.get_connection()
|
||||
|
||||
# Build the subscription request from a superset of all client
|
||||
# subscriptions
|
||||
sub = args.get('objects', {})
|
||||
if conn is None:
|
||||
raise self.error(
|
||||
"No connection associated with subscription request")
|
||||
self.subscriptions[conn] = sub
|
||||
all_subs: Dict[str, Any] = {}
|
||||
# request superset of all client subscriptions
|
||||
for sub in self.subscriptions.values():
|
||||
for obj, items in sub.items():
|
||||
if obj in all_subs:
|
||||
pi = all_subs[obj]
|
||||
if items is None or pi is None:
|
||||
all_subs[obj] = None
|
||||
else:
|
||||
uitems = list(set(pi) | set(items))
|
||||
all_subs[obj] = uitems
|
||||
else:
|
||||
all_subs[obj] = items
|
||||
args['objects'] = all_subs
|
||||
args['response_template'] = {'method': "process_status_update"}
|
||||
|
||||
result = await self._request_standard(web_request)
|
||||
|
||||
# prune the status response
|
||||
pruned_status = {}
|
||||
all_status = result['status']
|
||||
sub = self.subscriptions.get(conn, {})
|
||||
for obj, fields in all_status.items():
|
||||
if obj in sub:
|
||||
valid_fields = sub[obj]
|
||||
if valid_fields is None:
|
||||
pruned_status[obj] = fields
|
||||
else:
|
||||
pruned_status[obj] = {k: v for k, v in fields.items()
|
||||
if k in valid_fields}
|
||||
result['status'] = pruned_status
|
||||
return result
|
||||
|
||||
async def _request_standard(self, web_request: WebRequest) -> Any:
|
||||
rpc_method = web_request.get_endpoint()
|
||||
args = web_request.get_args()
|
||||
# Create a base klippy request
|
||||
base_request = BaseRequest(rpc_method, args)
|
||||
self.pending_requests[base_request.id] = base_request
|
||||
self.event_loop.register_callback(
|
||||
self.klippy_connection.send_request, base_request)
|
||||
return await base_request.wait()
|
||||
|
||||
def remove_subscription(self, conn: Subscribable) -> None:
|
||||
self.subscriptions.pop(conn, None)
|
||||
return self.klippy_connection.state
|
||||
|
||||
def _handle_term_signal(self) -> None:
|
||||
logging.info(f"Exiting with signal SIGTERM")
|
||||
|
@ -686,17 +350,17 @@ class Server:
|
|||
|
||||
# Disconnect from Klippy
|
||||
try:
|
||||
if self.klippy_connection.is_connected():
|
||||
self.klippy_disconnect_evt = asyncio.Event()
|
||||
await self.klippy_connection.close()
|
||||
await asyncio.wait_for(
|
||||
self.klippy_disconnect_evt.wait(), 2.)
|
||||
self.klippy_disconnect_evt = None
|
||||
await asyncio.wait_for(
|
||||
asyncio.shield(self.klippy_connection.close(
|
||||
wait_closed=True)), 2.)
|
||||
except Exception:
|
||||
logging.exception("Klippy Disconnect Error")
|
||||
|
||||
# Close all components
|
||||
for name, component in self.components.items():
|
||||
if name in ["application", "websockets", "klippy_connection"]:
|
||||
# These components have already been closed
|
||||
continue
|
||||
if hasattr(component, "close"):
|
||||
func = getattr(component, "close")
|
||||
try:
|
||||
|
@ -726,7 +390,7 @@ class Server:
|
|||
wsm: WebsocketManager = self.lookup_component('websockets')
|
||||
return {
|
||||
'klippy_connected': self.klippy_connection.is_connected(),
|
||||
'klippy_state': self.klippy_state,
|
||||
'klippy_state': self.klippy_connection.state,
|
||||
'components': list(self.components.keys()),
|
||||
'failed_components': self.failed_components,
|
||||
'registered_directories': reg_dirs,
|
||||
|
@ -742,130 +406,6 @@ class Server:
|
|||
'config': self.config.get_parsed_config()
|
||||
}
|
||||
|
||||
class KlippyConnection:
|
||||
def __init__(self,
|
||||
on_recd: Callable[[dict], None],
|
||||
on_close: Callable[[], None],
|
||||
event_loop: EventLoop
|
||||
) -> None:
|
||||
self.writer: Optional[asyncio.StreamWriter] = None
|
||||
self.connection_mutex: asyncio.Lock = asyncio.Lock()
|
||||
self.on_recd = on_recd
|
||||
self.on_close = on_close
|
||||
self.event_loop = event_loop
|
||||
self.log_no_access = True
|
||||
|
||||
async def connect(self, address: str) -> bool:
|
||||
if self.is_connected():
|
||||
await self.close()
|
||||
async with self.connection_mutex:
|
||||
if not os.path.exists(address):
|
||||
return False
|
||||
if not os.access(address, os.R_OK | os.W_OK):
|
||||
if self.log_no_access:
|
||||
user = getpass.getuser()
|
||||
logging.info(
|
||||
f"Cannot connect to Klippy, Linux user '{user}' lacks "
|
||||
f"permission to open Unix Domain Socket: {address}")
|
||||
self.log_no_access = False
|
||||
return False
|
||||
self.log_no_access = True
|
||||
try:
|
||||
reader, writer = await asyncio.open_unix_connection(
|
||||
address, limit=UNIX_BUFFER_LIMIT)
|
||||
except Exception:
|
||||
return False
|
||||
logging.info("Klippy Connection Established")
|
||||
self.writer = writer
|
||||
self.event_loop.register_callback(self._read_stream, reader)
|
||||
return True
|
||||
|
||||
async def _read_stream(self, reader: asyncio.StreamReader) -> None:
|
||||
errors_remaining: int = 10
|
||||
while not reader.at_eof() and errors_remaining:
|
||||
try:
|
||||
data = await reader.readuntil(b'\x03')
|
||||
except (ConnectionError, asyncio.IncompleteReadError):
|
||||
break
|
||||
except Exception:
|
||||
logging.exception("Klippy Stream Read Error")
|
||||
errors_remaining -= 1
|
||||
continue
|
||||
errors_remaining = 10
|
||||
try:
|
||||
decoded_cmd = json.loads(data[:-1])
|
||||
self.on_recd(decoded_cmd)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
f"Error processing Klippy Host Response: {data.decode()}")
|
||||
await self.close()
|
||||
|
||||
async def send_request(self, request: BaseRequest) -> None:
|
||||
if self.writer is None:
|
||||
request.notify(ServerError("Klippy Host not connected", 503))
|
||||
return
|
||||
data = json.dumps(request.to_dict()).encode() + b"\x03"
|
||||
try:
|
||||
self.writer.write(data)
|
||||
await self.writer.drain()
|
||||
except Exception:
|
||||
request.notify(ServerError("Klippy Host not connected", 503))
|
||||
await self.close()
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
return self.writer is not None
|
||||
|
||||
async def close(self) -> None:
|
||||
if (
|
||||
self.connection_mutex.locked() or
|
||||
self.writer is None
|
||||
):
|
||||
return
|
||||
async with self.connection_mutex:
|
||||
try:
|
||||
self.writer.close()
|
||||
await self.writer.wait_closed()
|
||||
except Exception:
|
||||
logging.exception("Error closing Klippy Unix Socket")
|
||||
self.writer = None
|
||||
self.on_close()
|
||||
|
||||
|
||||
# Basic WebRequest class, easily converted to dict for json encoding
|
||||
class BaseRequest:
|
||||
def __init__(self, rpc_method: str, params: Dict[str, Any]) -> None:
|
||||
self.id = id(self)
|
||||
self.rpc_method = rpc_method
|
||||
self.params = params
|
||||
self._event = asyncio.Event()
|
||||
self.response: Any = None
|
||||
|
||||
async def wait(self) -> Any:
|
||||
# Log pending requests every 60 seconds
|
||||
start_time = time.time()
|
||||
while True:
|
||||
try:
|
||||
await asyncio.wait_for(self._event.wait(), 60.)
|
||||
except asyncio.TimeoutError:
|
||||
pending_time = time.time() - start_time
|
||||
logging.info(
|
||||
f"Request '{self.rpc_method}' pending: "
|
||||
f"{pending_time:.2f} seconds")
|
||||
self._event.clear()
|
||||
continue
|
||||
break
|
||||
if isinstance(self.response, ServerError):
|
||||
raise self.response
|
||||
return self.response
|
||||
|
||||
def notify(self, response: Any) -> None:
|
||||
self.response = response
|
||||
self._event.set()
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {'id': self.id, 'method': self.rpc_method,
|
||||
'params': self.params}
|
||||
|
||||
def main(cmd_line_args: argparse.Namespace) -> None:
|
||||
cfg_file = cmd_line_args.configfile
|
||||
app_args = {'config_file': cfg_file}
|
||||
|
|
|
@ -28,6 +28,7 @@ from typing import (
|
|||
if TYPE_CHECKING:
|
||||
from moonraker import Server
|
||||
from app import APIDefinition
|
||||
from klippy_connection import KlippyConnection as Klippy
|
||||
import components.authorization
|
||||
_T = TypeVar("_T")
|
||||
_C = TypeVar("_C", str, bool, float, int)
|
||||
|
@ -270,6 +271,7 @@ class APITransport:
|
|||
class WebsocketManager(APITransport):
|
||||
def __init__(self, server: Server) -> None:
|
||||
self.server = server
|
||||
self.klippy: Klippy = server.lookup_component("klippy_connection")
|
||||
self.websockets: Dict[int, WebSocket] = {}
|
||||
self.rpc = JsonRPC()
|
||||
self.closed_event: Optional[asyncio.Event] = None
|
||||
|
@ -311,7 +313,7 @@ class WebsocketManager(APITransport):
|
|||
|
||||
def _generate_callback(self, endpoint: str) -> RPCCallback:
|
||||
async def func(ws: WebSocket, **kwargs) -> Any:
|
||||
result = await self.server.make_request(
|
||||
result = await self.klippy.request(
|
||||
WebRequest(endpoint, kwargs, conn=ws, ip_addr=ws.ip_addr,
|
||||
user=ws.current_user))
|
||||
return result
|
||||
|
@ -348,7 +350,7 @@ class WebsocketManager(APITransport):
|
|||
def remove_websocket(self, ws: WebSocket) -> None:
|
||||
old_ws = self.websockets.pop(ws.uid, None)
|
||||
if old_ws is not None:
|
||||
self.server.remove_subscription(old_ws)
|
||||
self.klippy.remove_subscription(old_ws)
|
||||
logging.debug(f"Websocket Removed: {ws.uid}")
|
||||
if self.closed_event is not None and not self.websockets:
|
||||
self.closed_event.set()
|
||||
|
|
Loading…
Reference in New Issue