// Main javascript for for Klippy Web Server Example // // Copyright (C) 2019 Eric Callahan // // This file may be distributed under the terms of the GNU GPLv3 license import JsonRPC from "./json-rpc.js?v=0.1.2"; var origin = location.origin; var ws_url = (window.location.protocol == "https" ? "wss://" : "ws://") + location.host; // API Definitions var api = { printer_info: { url: "/printer/info", method: "printer.info" }, gcode_script: { url: "/printer/gcode/script", method: "printer.gcode.script" }, gcode_help: { url: "/printer/gcode/help", method: "printer.gcode.help" }, start_print: { url: "/printer/print/start", method: "printer.print.start" }, cancel_print: { url: "/printer/print/cancel", method: "printer.print.cancel" }, pause_print: { url: "/printer/print/pause", method: "printer.print.pause" }, resume_print: { url: "/printer/print/resume", method: "printer.print.resume" }, query_endstops: { url: "/printer/query_endstops/status", method: "printer.query_endstops.status" }, object_list: { url: "/printer/objects/list", method: "printer.objects.list" }, object_status: { url: "/printer/objects/query", method: "printer.objects.query" }, object_subscription: { url: "/printer/objects/subscribe", method: "printer.objects.subscribe" }, temperature_store: { url: "/server/temperature_store", method: "server.temperature_store" }, estop: { url: "/printer/emergency_stop", method: "printer.emergency_stop" }, restart: { url: "/printer/restart", method: "printer.restart" }, firmware_restart: { url: "/printer/firmware_restart", method: "printer.firmware_restart" }, // File Management Apis file_list:{ url: "/server/files/list", method: "server.files.list" }, metadata: { url: "/server/files/metadata", method: "server.files.metadata" }, directory: { url: "/server/files/directory", method: { get: "server.files.get_directory", post: "server.files.post_directory", delete: "server.files.delete_directory" } }, move: { url: "/server/files/move", method: "server.files.move" }, copy: { url: "/server/files/copy", method: "server.files.copy" }, delete_file: { method: "server.files.delete_file" }, upload: { url: "/server/files/upload" }, gcode_files: { url: "/server/files/gcodes/" }, klippy_log: { url: "/server/files/klippy.log" }, moonraker_log: { url: "/server/files/moonraker.log" }, cfg_files: { url: "/server/files/config/" }, cfg_examples: { url: "/server/files/config_examples/" }, // Machine APIs reboot: { url: "/machine/reboot", method: "machine.reboot" }, shutdown: { url: "/machine/shutdown", method: "machine.shutdown" }, // Access APIs apikey: { url: "/access/api_key" }, oneshot_token: { url: "/access/oneshot_token" } } var websocket = null; var apikey = null; var paused = false; var klippy_ready = false; var api_type = 'http'; var is_printing = false; var json_rpc = new JsonRPC(); function round_float (value) { if (typeof value == "number" && !Number.isInteger(value)) { return value.toFixed(2); } return value; } //****************UI Update Functions****************/ var line_count = 0; function update_term(msg) { let start = '
'; $("#term").append(start + msg + "
"); line_count++; if (line_count >= 50) { let rm = line_count - 50 $("#line" + rm).remove(); } if ($("#cbxAuto").is(":checked")) { $("#term").stop().animate({ scrollTop: $("#term")[0].scrollHeight }, 800); } } const max_stream_div_width = 5; var stream_div_width = max_stream_div_width; var stream_div_height = 0; function update_streamdiv(obj, attr, val) { if (stream_div_width >= max_stream_div_width) { stream_div_height++; stream_div_width = 0; $('#streamdiv').append("
"); } let id = obj.replace(/\s/g, "_") + "_" + attr; if ($("#" + id).length == 0) { $('#sdrow' + stream_div_height).append("
" + obj + " " + attr + ":
"); stream_div_width++; } let out = ""; if (val instanceof Array) { val.forEach((value, idx, array) => { out += round_float(value); if (idx < array.length -1) { out += ", " } }); } else { out = round_float(val); } $("#" + id).text(out); } var last_progress = 0; function update_progress(loaded, total) { let progress = parseInt(loaded / total * 100); if (progress - last_progress > 1 || progress >= 100) { if (progress >= 100) { last_progress = 0; progress = 100; console.log("File transfer complete") } else { last_progress = progress; } $('#upload_progress').text(progress); $('#progressbar').val(progress); } } function update_error(cmd, msg) { if (msg instanceof Object) msg = JSON.stringify(msg); // Handle server error responses update_term("Command [" + cmd + "] resulted in an error: " + msg); console.log("Error processing " + cmd +": " + msg); } function handle_klippy_state(state) { // Klippy state can be "ready", "disconnect", and "shutdown". This // differs from Printer State in that it represents the status of // the Host software switch(state) { case "ready": // It would be possible to use this event to notify the // client that the printer has started, however the server // may not start in time for clients to receive this event. // It is being kept in case if (!klippy_ready) update_term("Klippy Ready"); break; case "shutdown": // Either M112 was entered or there was a printer error. We // probably want to notify the user and disable certain controls. klippy_ready = false; update_term("Klipper has shutdown, check klippy.log for info"); break; } } function process_mesh(result) { let bed_mesh = result.status.bed_mesh; let matrix = bed_mesh.probed_matrix; if (!(matrix instanceof Array) || matrix.length < 3 || !(matrix[0] instanceof Array) || matrix[0].length < 3) { // make sure that the matrix is valid console.log("Invalid Mesh Received"); return; } let coordinates = []; let x_distance = (bed_mesh.mesh_max[0] - bed_mesh.mesh_min[0]) / (matrix[0].length - 1); let y_distance = (bed_mesh.mesh_max[1] - bed_mesh.mesh_min[1]) / (matrix.length - 1); let x_idx = 0; let y_idx = 0; for (const x_axis of matrix) { x_idx = 0; let y_coord = bed_mesh.mesh_min[1] + (y_idx * y_distance); for (const z_coord of x_axis) { let x_coord = bed_mesh.mesh_min[0] + (x_idx * x_distance); x_idx++; coordinates.push([x_coord, y_coord, z_coord]); } y_idx++; } console.log("Processed Mesh Coordinates:"); console.log(coordinates); } //***********End UI Update Functions****************/ //***********Websocket-Klipper API Functions (JSON-RPC)************/ function get_file_list(root) { let args = {root: root} json_rpc.call_method_with_kwargs(api.file_list.method, args) .then((result) => { // result is an "ok" acknowledgment that the gcode has // been successfully processed console.log(result); }) .catch((error) => { update_error(api.file_list.method, error); }); } function get_klippy_info() { // A "get_klippy_info" websocket request. It returns // the hostname (which should be equal to location.host), the // build version, and if the Host is ready for commands. Its a // good idea to fetch this information after the websocket connects. // If the Host is in a "ready" state, we can do some initialization json_rpc.call_method(api.printer_info.method) .then((result) => { if (websocket.id == null) get_websocket_id(); if (result.state == "ready") { if (!klippy_ready) { update_term("Klippy Hostname: " + result.hostname + " | CPU: " + result.cpu_info + " | Build Version: " + result.software_version); klippy_ready = true; // Add our subscriptions the the UI is configured to do so. if ($("#cbxSub").is(":checked")) { // If autosubscribe is check, request the subscription now const sub = { objects: { gcode_move: ["gcode_position", "speed", "speed_factor", "extrude_factor"], idle_timeout: null, pause_resume: null, toolhead: null, virtual_sdcard: null, heater_bed: null, extruder: ["temperature", "target"], fan: null, print_stats: null} }; add_subscription(sub); } else { get_status({idle_timeout: null, pause_resume: null}); } } } else { if (result.state == "error") { update_term(result.state_message); } else { update_term("Waiting for Klippy ready status..."); } console.log("Klippy Not Ready, checking again in 2s: "); setTimeout(() => { get_klippy_info(); }, 2000); } }) .catch((error) => { update_error(api.printer_info.method, error); setTimeout(() => { get_klippy_info(); }, 2000); }); } function get_websocket_id() { json_rpc.call_method("server.websocket.id") .then((result) => { // result is an "ok" acknowledgment that the gcode has // been successfully processed websocket.id = result.websocket_id; console.log(`Websocket ID Received: ${result.websocket_id}`); }) .catch((error) => { update_error("server.websocket.id", error); }); } function run_gcode(gcode) { json_rpc.call_method_with_kwargs( api.gcode_script.method, {script: gcode}) .then((result) => { // result is an "ok" acknowledgment that the gcode has // been successfully processed update_term(result); }) .catch((error) => { update_error(api.gcode_script.method, error); }); } function get_status(printer_objects) { // Note that this is just an example of one particular use of get_status. // In a robust client you would likely pass a callback to this function // so that you can respond to various status requests. It would also // be possible to subscribe to status requests and update the UI accordingly json_rpc.call_method_with_kwargs(api.object_status.method, printer_objects) .then((result) => { if ("idle_timeout" in result) { // Its a good idea that the user understands that some functionality, // such as file manipulation, is disabled during a print. This can be // done by disabling buttons or by notifying the user via a popup if they // click on an action that is not allowed. if ("state" in result.idle_timeout) { let state = result.idle_timeout.state.toLowerCase(); is_printing = (state == "printing"); if (!$('#cbxFileTransfer').is(":checked")) { $('.toggleable').prop( 'disabled', (api_type == 'websocket' || is_printing)); } $('#btnstartprint').prop('disabled', is_printing); } } if ("pause_resume" in result) { if ("is_paused" in result.pause_resume) { paused = result.pause_resume.is_paused; let label = paused ? "Resume Print" : "Pause Print"; $('#btnpauseresume').text(label); } } console.log(result); }) .catch((error) => { update_error(api.object_status.method, error); }); } function get_object_list() { json_rpc.call_method(api.object_list.method) .then((result) => { // result will be a dictionary containing all available printer // objects available for query or subscription console.log(result); }) .catch((error) => { update_error(api.object_list.method, error); }); } function get_mesh() { json_rpc.call_method_with_kwargs( api.object_status.method, {objects: {bed_mesh: null}}) .then((result) => { process_mesh(result); }) .catch((error) => { update_error(api.object_status.method, error); }); } function add_subscription(printer_objects) { json_rpc.call_method_with_kwargs( api.object_subscription.method, printer_objects) .then((result) => { // result is the the state from all fetched data handle_status_update(result.status) console.log(result); }) .catch((error) => { update_error(api.object_subscription.method, error); }); } function get_subscribed() { json_rpc.call_method(api.object_subscription.method) .then((result) => { // result is a dictionary containing all currently subscribed // printer objects/attributes console.log(result); }) .catch((error) => { update_error(api.object_subscription.method, error); }); } function get_endstops() { json_rpc.call_method(api.query_endstops.method) .then((result) => { // A response to a "get_endstops" websocket request. // The result contains an object of key/value pairs, // where the key is the endstop (ie:x, y, or z) and the // value is either "open" or "TRIGGERED". console.log(result); }) .catch((error) => { update_error(api.query_endstops.method, error); }); } function start_print(file_name) { json_rpc.call_method_with_kwargs( api.start_print.method, {'filename': file_name}) .then((result) => { // result is an "ok" acknowledgement that the // print has started console.log(result); }) .catch((error) => { update_error(api.start_print.method, error); }); } function cancel_print() { json_rpc.call_method(api.cancel_print.method) .then((result) => { // result is an "ok" acknowledgement that the // print has been canceled console.log(result); }) .catch((error) => { update_error(api.cancel_print.method, error); }); } function pause_print() { json_rpc.call_method(api.pause_print.method) .then((result) => { // result is an "ok" acknowledgement that the // print has been paused console.log("Pause Command Executed") }) .catch((error) => { update_error(api.pause_print.method, error); }); } function resume_print() { json_rpc.call_method(api.resume_print.method) .then((result) => { // result is an "ok" acknowledgement that the // print has been resumed console.log("Resume Command Executed") }) .catch((error) => { update_error(api.resume_print.method, error); }); } function get_metadata(file_name) { json_rpc.call_method_with_kwargs( api.metadata.method, {'filename': file_name}) .then((result) => { // result is an "ok" acknowledgement that the // print has started console.log(result); }) .catch((error) => { update_error(api.metadata.method, error); }); } function make_directory(dir_path) { json_rpc.call_method_with_kwargs( api.directory.method.post, {'path': dir_path}) .then((result) => { // result is an "ok" acknowledgement that the // print has started console.log(result); }) .catch((error) => { update_error(api.directory.method.post, error); }); } function delete_directory(dir_path) { json_rpc.call_method_with_kwargs( api.directory.method.delete, {'path': dir_path, 'force': true}) .then((result) => { // result is an "ok" acknowledgement that the // print has started console.log(result); }) .catch((error) => { update_error(api.directory.method.delete, error); }); } function delete_file(file_path) { json_rpc.call_method_with_kwargs( api.delete_file.method, {'path': file_path}) .then((result) => { // result is an "ok" acknowledgement that the // print has started console.log(result); }) .catch((error) => { update_error(api.delete_file.method, error); }); } function copy_item(source_path, dest_path) { json_rpc.call_method_with_kwargs( api.copy.method, {'source': source_path, 'dest': dest_path}) .then((result) => { // result is an "ok" acknowledgement that the // print has started console.log(result); }) .catch((error) => { update_error(api.copy.method, error); }); } function move_item(source_path, dest_path) { json_rpc.call_method_with_kwargs( api.move.method, {'source': source_path, 'dest': dest_path}) .then((result) => { // result is an "ok" acknowledgement that the // print has started console.log(result); }) .catch((error) => { update_error(api.move.method, error); }); } function get_gcode_help() { json_rpc.call_method(api.gcode_help.method) .then((result) => { // result is an "ok" acknowledgement that the // print has been resumed console.log(result) }) .catch((error) => { update_error(api.gcode_help.method, error); }); } function emergency_stop() { json_rpc.call_method(api.estop.method) .then((result) => { // result is an "ok" acknowledgement that the // print has been resumed console.log(result) }) .catch((error) => { update_error(api.estop.method, error); }); } function restart() { // We are unlikely to receive a response from a restart // request as the websocket will disconnect, so we will // call json_rpc.notify instead of call_function. json_rpc.notify(api.restart.method); } function firmware_restart() { // As above, we would not likely receive a response from // a firmware_restart request json_rpc.notify(api.firmware_restart.method); } function reboot() { json_rpc.notify(api.reboot.method); } function shutdown() { json_rpc.notify(api.shutdown.method); } //***********End Websocket-Klipper API Functions (JSON-RPC)********/ //***********Klipper Event Handlers (JSON-RPC)*********************/ function handle_gcode_response(response) { // This event contains all gcode responses that would // typically be printed to the terminal. Its possible // That multiple lines can be bundled in one response, // so if displaying we want to be sure we split them. let messages = response.split("\n"); for (let msg of messages) { update_term(msg); } } json_rpc.register_method("notify_gcode_response", handle_gcode_response); function handle_status_update(status) { // This is subscribed status data. Here we do a nested // for-each to determine the klippy object name ("name"), // the attribute we want ("attr"), and the attribute's // value ("val") for (let name in status) { let obj = status[name]; for (let attr in obj) { let full_name = name + "." + attr; let val = obj[attr]; switch(full_name) { case "print_stats.filename": $('#filename').prop("hidden", val == ""); $('#filename').text("Loaded File: " + val); break; case "pause_resume.is_paused": if (paused != val) { paused = val; let label = paused ? "Resume Print" : "Pause Print"; $('#btnpauseresume').text(label); console.log("Paused State Changed: " + val); update_streamdiv(name, attr, val); } break; case "idle_timeout.state": let state = val.toLowerCase(); if (state != is_printing) { is_printing = (state == "printing"); if (!$('#cbxFileTransfer').is(":checked")) { $('.toggleable').prop( 'disabled', (api_type == 'websocket' || is_printing)); } $('#btnstartprint').prop('disabled', is_printing); update_streamdiv(name, attr, val); } break; case "webhooks.state": handle_klippy_state(val); default: update_streamdiv(name, attr, val); } } } } json_rpc.register_method("notify_status_update", handle_status_update); function handle_klippy_disconnected() { // Klippy has disconnected from the MCU and is prepping to // restart. The client will receive this signal right before // the websocket disconnects. If we need to do any kind of // cleanup on the client to prepare for restart this would // be a good place. klippy_ready = false; update_term("Klippy Disconnected"); setTimeout(() => { get_klippy_info(); }, 2000); } json_rpc.register_method("notify_klippy_disconnected", handle_klippy_disconnected); function handle_file_list_changed(file_info) { // This event fires when a client has either added or removed // a gcode file. // Update the jstree based on the action and info let parent_node = parse_node_path(file_info.item.root, file_info.item.path); $("#filelist").jstree('refresh_node', parent_node); if (file_info.action == "move_item") { let src_parent_node = parse_node_path( file_info.source_item.root, file_info.source_item.path) if (src_parent_node != parent_node) $("#filelist").jstree('refresh_node', src_parent_node); } console.log("Filelist Changed:"); console.log(file_info); } json_rpc.register_method("notify_filelist_changed", handle_file_list_changed); function handle_metadata_update(metadata) { console.log(metadata); } json_rpc.register_method("notify_metadata_update", handle_metadata_update); //***********End Klipper Event Handlers (JSON-RPC)*****************/ //*****************Websocket Batch GCode Tests*********************/ // The function below is an example of one way to use JSON-RPC's batch send // method. Generally speaking it is better and easier to use individual // requests, as matching requests with responses in a batch requires more // work from the developer function send_gcode_batch(gcodes) { // The purpose of this function is to provide an example of a JSON-RPC // "batch request". This function takes an array of gcodes and sends // them as a batch command. This would behave like a Klipper Gcode Macro // with one signficant difference...if one gcode in the batch requests // results in an error, Klipper will continue to process subsequent gcodes. // A Klipper Gcode Macro will immediately stop execution of the macro // if an error is encountered. let batch = []; for (let gc of gcodes) { batch.push( { method: api.gcode_script.method, type: 'request', params: {script: gc} }); } // The batch request returns a promise with all results json_rpc.send_batch_request(batch) .then((results) => { for (let res of results) { // Each result is an object with three keys: // method: The method executed for this result // index: The index of the original request // result: The successful result // Use the index to look up the gcode parameter in the original // request let orig_gcode = batch[res.index].params[0]; console.log("Batch Gcode " + orig_gcode + " successfully executed with result: " + res.result); } }) .catch((err) => { // Like the result, the error is an object. However there // is an "error" in place of the "result key" let orig_gcode = batch[err.index].params[0]; console.log("Batch Gcode <" + orig_gcode + "> failed with error: " + err.error.message); }); } // The function below demonstrates a more useful method of sending // a client side gcode macro. Like a Klipper macro, gcode execution // will stop immediately upon encountering an error. The advantage // of a client supporting their own macros is that there is no need // to restart the klipper host after creating or deleting them. async function send_gcode_macro(gcodes) { for (let gc of gcodes) { try { let result = await json_rpc.call_method_with_kwargs( api.gcode_script.method, {script: gc}); } catch (err) { console.log("Error executing gcode macro: " + err.message); break; } } } //**************End Websocket Batch GCode Tests********************/ //*****************HTTP Helper Functions***************************/ function encode_filename(path) { let parts = path.split("/") if (!parts.length) { return ""; } let fname = encodeURIComponent(parts.pop()) if (parts.length) { return parts.join("/") + "/" + fname; } return fname; } function run_request(url, method, callback=null) { let settings = { url: url, method: method, success: (resp, status) => { console.log(resp); if (callback != null) callback(resp) return false; }}; if (websocket.id != null) { let fdata = new FormData(); fdata.append("connection_id", websocket.id); settings.data = fdata settings.contentType = false, settings.processData = false } if (apikey != null) settings.headers = {"X-Api-Key": apikey}; $.ajax(settings); } function form_get_request(api_url, query_string="", callback=null) { let url = origin + api_url + query_string; run_request(url, 'GET', callback); } function form_post_request(api_url, query_string="", callback=null) { let url = origin + api_url + query_string; run_request(url, 'POST', callback); } function form_delete_request(api_url, query_string="", callback=null) { let url = origin + api_url + query_string; run_request(url, 'DELETE', callback); } function form_download_request(uri) { let dl_url = origin + uri; if (apikey != null) { form_get_request(api.oneshot_token.url, callback=(resp) => { let token = resp.result; dl_url += "?token=" + token; do_download(dl_url); return false; }); } else { do_download(dl_url); } } //*************End HTTP Helper Functions***************************/ //***************JSTree Helper Functions***************************/ function parse_node_path(root, path) { let slice_idx = path.lastIndexOf("/"); let node_path = ""; if (slice_idx != -1) node_path = "/" + path.slice(0, slice_idx); return root + node_path; } function get_selected_node() { let js_instance = $("#filelist").jstree(true); let sel = js_instance.get_selected(); if (!sel.length) return null; sel = sel[0]; return js_instance.get_node(sel); } function get_selected_item(type="file") { let node = get_selected_node(); if (node == null || node.type != type) return ""; return node.id; } function generate_children(result, parent) { let children = []; result.dirs.sort((a, b) => { return a.dirname > b.dirname ? 1 : -1; }) result.files.sort((a, b) => { return a.filename > b.filename ? 1 : -1; }); for (let dir of result.dirs) { let full_path = parent.id + "/" + dir.dirname; children.push({text: dir.dirname, id: full_path, type: "dir", children: true, mutable: parent.original.mutable}); } for (let file of result.files) { let full_path = parent.id + "/" + file.filename; children.push({text: file.filename, id: full_path, type: "file", mutable: parent.original.mutable}); } return children; } function jstree_populate_children(node, callback) { if (api_type == "http") { let qs = `?path=${node.id}`; let settings = {url: origin + api.directory.url + qs}; if (apikey != null) settings.headers = {"X-Api-Key": apikey}; $.get(settings, (resp, status) => { callback(generate_children(resp.result, node)); return false; }); } else { json_rpc.call_method_with_kwargs( api.directory.method.get, {path: node.id}) .then((result) => { callback(generate_children(result, node)); }) .catch((error) => { console.log(error) update_error(api.directory.method.get, error); }); } } function jstree_download_file() { update_progress(0, 100); let filename = get_selected_item(); if (filename) { let url = `/server/files/${encode_filename(filename)}`; form_download_request(url); } } var paste_item = null; function jstree_paste() { if (paste_item == null) { console.log(`Invalid Paste Command`); return } let node = get_selected_node() if (node == null || node == paste_item.source_node) { paste_item = null; return; } let source_path = paste_item.source_node.id; let dest_path = node.id; // TODO: Need checks here. // - If the source is a file and the action is move, the destination must // - be a directory? if (paste_item.source_node.id == node.id) { // Can't move or copy to the same item return; } else if (paste_item.source_node.type != "file" && node.type == "file") { // Can't copy or move a directory into a file return; } else if (paste_item.action == "copy" && node.type != "file") { // When copying to a folder add the file/folder name to the destination dest_path += `/${paste_item.source_node.text}`; } if (api_type == 'http') { let api_url = paste_item.action == "move" ? api.move.url : api.copy.url; let qs = `?source=${source_path}&dest=${dest_path}`; form_post_request(api_url, qs); } else { // Websocket if (paste_item.action == "move") move_item(source_path, dest_path); else copy_item(source_path, dest_path); } paste_item = null; } function jstree_delete_item() { let node = get_selected_node(); if (node == null) { console.log("Invalid item selection, cannot delete"); } if (api_type == 'http') { let api_url; let qs = ""; if (node.type == "file") { api_url = `/server/files/${encode_filename(node.id)}`; } else { api_url = api.directory.url; qs = `?path=${encode_filename(node.id)}&force=true`; } form_delete_request(api_url, qs); } else { if (node.type == "file") delete_file(node.id); else delete_directory(node.id); } } function jstree_new_folder(node, status, cancelled) { if (!status) return; let instance = $("#filelist").jstree(true); if (cancelled) { console.log("Create Folder Cancelled") instance.delete_node(node); return; } console.log(`Create Folder: ${node}`); let parent = instance.get_node(instance.get_parent(node)); if (parent.type == "file") { console.log("Invalid folder, cannot create") instance.delete_node(node); return; } let path = parent.id + "/" + node.text; if (api_type == 'http') { let url = api.directory.url; let qs = "?path=" + path; form_post_request(url, qs); } else { make_directory(path); } } function jstree_rename(node, status, cancelled) { if (!status || cancelled) return; let source_path = node.id; let instance = $("#filelist").jstree(true); let dest_path = instance.get_parent(node) + "/" + node.text; if (api_type == 'http') { let qs = `?source=${source_path}&dest=${dest_path}`; form_post_request(api.move.url, qs); } else { move_item(source_path, dest_path); } } function jstree_start_print() { let filename = get_selected_item(); if (filename && filename.startsWith("gcodes/")) { filename = filename.slice(7) let qs = `?filename=${encode_filename(filename)}`; if (api_type == 'http') { form_post_request(api.start_print.url, qs); } else { start_print(filename); } } } function jstree_get_metadata() { let filename = get_selected_item(); if (filename && filename.startsWith("gcodes/")) { filename = filename.slice(7); if (api_type == 'http') { let qs = `?filename=${encode_filename(filename)}`; form_get_request(api.metadata.url, qs); } else { get_metadata(filename); } } } //***********End JSTree Helper Functions***************************/ // A simple reconnecting websocket class KlippyWebsocket { constructor(addr) { this.base_address = addr; this.connected = false; this.ws = null; this.onmessage = null; this.onopen = null; this.id = null; this.connect(); } connect() { // Doing the websocket connection here allows the websocket // to reconnect if its closed. This is nice as it allows the // client to easily recover from Klippy restarts without user // intervention if (apikey != null) { // Fetch a oneshot token to pass websocket authorization let token_settings = { url: origin + api.oneshot_token.url, headers: { "X-Api-Key": apikey } } $.get(token_settings, (data, status) => { let token = data.result; let url = this.base_address + "/websocket?token=" + token; this.ws = new WebSocket(url); this._set_callbacks(); }).fail(() => { console.log("Failed to retreive oneshot token"); }) } else { this.ws = new WebSocket(this.base_address + "/websocket"); this._set_callbacks(); } } _set_callbacks() { this.ws.onopen = () => { this.connected = true; console.log("Websocket connected"); if (this.onopen != null) this.onopen(); }; this.ws.onclose = (e) => { klippy_ready = false; this.connected = false; this.id = null; console.log("Websocket Closed, reconnecting in 1s: ", e.reason); setTimeout(() => { this.connect(); }, 1000); }; this.ws.onerror = (err) => { klippy_ready = false; console.log("Websocket Error: ", err.message); this.ws.close(); }; this.ws.onmessage = (e) => { // Tornado Server Websockets support text encoded frames. // The onmessage callback will send the data straight to // JSON-RPC this.onmessage(e.data); }; } send(data) { // Only allow send if connected if (this.connected) { this.ws.send(data); } else { console.log("Websocket closed, cannot send data"); } } close() { // TODO: Cancel the timeout this.ws.close(); } }; function create_websocket(url) { if (websocket != null) websocket.close() websocket = new KlippyWebsocket(ws_url); websocket.onopen = () => { // Depending on the state of the printer, all enpoints may not be // available when the websocket is first opened. The "get_klippy_info" // method is available, and should be used to determine if Klipper is // in the "ready" state. When Klipper is "ready", all endpoints should // be registered and available. // These could be implemented JSON RPC Batch requests and send both // at the same time, however it is easier to simply do them // individually get_klippy_info(); }; json_rpc.register_transport(websocket); } function check_authorization() { // send a HTTP "run gcode" command let settings = { url: origin + api.printer_info.url, statusCode: { 401: function() { // Show APIKey Popup let result = window.prompt("Enter a valid API Key:", ""); if (result == null || result.length != 32) { console.log("Invalid API Key: " + result); apikey = null; } else { apikey = result; } check_authorization(); } } } if (apikey != null) settings.headers = {"X-Api-Key": apikey}; $.get(settings, (data, status) => { // Create a websocket if /printer/info successfully returns create_websocket(); }) } function do_download(url) { $('#hidden_link').attr('href', url); $('#hidden_link')[0].click(); } window.onload = () => { // Handle changes between the HTTP and Websocket API $('.reqws').prop('disabled', true); $('input[type=radio][name=test_type]').on('change', function() { api_type = $(this).val(); let disable_transfer = (!$('#cbxFileTransfer').is(":checked") && is_printing); $('.toggleable').prop( 'disabled', (api_type == 'websocket' || disable_transfer)); $('.reqws').prop('disabled', (api_type == 'http')); $('#apimethod').prop('hidden', (api_type == "websocket")); $('#apiargs').prop('hidden', (api_type == "http")); }); // Instantiate basic jstree $('#filelist').jstree({ core: { multiple: false, check_callback: true, data: function (node, cb) { if (node.id === "#") { cb([ {text: "gcodes", id: "gcodes", type: "root", children: true, mutable: true}, {text: "config", id: "config", type: "root", children: true, mutable: true}, {text: "config_examples", id: "config_examples", type: "root", children: true, mutable: false} ]); } else { jstree_populate_children(node, cb); } } }, types: { default: { icon: "jstree-folder" }, "#": { valid_children: ["root"], max_children: 3 }, root: { icon: "jstree-folder", valid_children: ["dir", "file"] }, file: { icon: "jstree-file", valid_children: [] }, dir: { icon: "jstree-folder", valid_children: ["dir", "file"] } }, contextmenu: { items: (node, cb) => { let actions = Object(); if (node.type != "file") { // Can upload, can paste actions.upload = { label: "Upload File", _disabled: !node.original.mutable, action: () => { $('#upload-file').click(); } } actions.new_folder = { label: "New Folder", _disabled: !node.original.mutable, action: () => { let instance = $("#filelist").jstree(true); let par = get_selected_node(); if (par == null) return false; instance.create_node(par, { type: "dir", }, "first", (ch_node) => { if (ch_node) { let val = instance.edit( ch_node.id, null, jstree_new_folder); if (val === false) { console.log(instance.last_error()); } } else { console.log("Invalid Child, cannot create dir"); } }); }, separator_after: true } } else { actions.download = { label: "Download File", action: jstree_download_file, separator_after: true } } if (node.type != "root") { if (node.type == "file" && node.id.startsWith("gcodes")) { actions.print = { label: "Start Print", _disabled: is_printing, action: jstree_start_print } actions.metadata = { label: "Get Metadata", separator_after: true, action: jstree_get_metadata } } // can delete, cut (move), copy, or rename(move) actions.rename = { label: "Rename", _disabled: !node.original.mutable, action: () => { let instance = $("#filelist").jstree(true); let cur = get_selected_node(); if (cur == null) return false; instance.edit(cur, null, jstree_rename); } } actions.delete = { label: "Delete", _disabled: !node.original.mutable, action: jstree_delete_item, separator_after: true } actions.edit = { label: "Edit", submenu: {} } actions.edit.submenu.cut = { label: "Cut", _disabled: !node.original.mutable, action: () => { paste_item = { action: "move", source_node: node } } } actions.edit.submenu.copy = { label: "Copy", action: () => { paste_item = { action: "copy", source_node: node } } } } if (!("edit" in actions)) { actions.edit = { label: "Edit", submenu: {} } } actions.edit.submenu.paste = { label: "Paste", _disabled: paste_item == null || paste_item.source_node.id == node.id || !node.original.mutable, action: jstree_paste } cb(actions); } }, plugins: ["types", "contextmenu"] }); $('#cbxFileTransfer').on('change', function () { let disabled = false; if (!$(this).is(":checked")) { disabled = (api_type == 'websocket' || is_printing); } $('.toggleable').prop( 'disabled', disabled); }); // Send a gcode. Note that in the test client nearly every control // checks a radio button to see if the request should be sent via // the REST API or the Websocket API. A real client will choose one // or the other, so the "api_type" check will be unnecessary $('#gcform').submit((evt) => { let line = $('#gcform [type=text]').val(); $('#gcform [type=text]').val(''); update_term(line); if (api_type == 'http') { // send a HTTP "run gcode" command let qs = "?script=" + line; form_post_request(api.gcode_script.url, qs); } else { // Send a websocket "run gcode" command. run_gcode(line); } return false; }); // Send a command to the server. This can be either an HTTP // get request formatted as the endpoint(ie: /objects) or // a websocket command. The websocket command needs to be // formatted as if it were already json encoded. $('#apiform').submit((evt) => { // Send to a user defined endpoint and log the response if (api_type == 'http') { let sendtype = $("input[type=radio][name=api_cmd_type]:checked").val(); let url = $('#apirequest').val(); let settings = {url: url} if (apikey != null) settings.headers = {"X-Api-Key": apikey}; if (sendtype == "get") { console.log("Sending GET " + url); form_get_request(url); } else if (sendtype == "post") { console.log("Sending POST " + url); form_post_request(url); } else if (sendtype == "delete") { console.log("Sending DELETE " + url); form_delete_request(url); } } else { let method = $('#apirequest').val().trim(); let args = $('#apiargs').val(); if (args != "") { try { args = JSON.parse("{" + args + "}"); } catch (error) { console.log("Unable to parse arguments"); return } json_rpc.call_method_with_kwargs(method, args) .then((result) => { console.log(result); }) .catch((error) => { update_error(method, error); }); } else { json_rpc.call_method(method) .then((result) => { console.log(result); }) .catch((error) => { update_error(method, error); }); } } return false; }); // Uploads a selected file to the server $('#upload-file').change(() => { update_progress(0, 100); let file = $('#upload-file').prop('files')[0]; let dir = get_selected_item("dir"); if (!dir) dir = get_selected_item("root"); if (file && dir) { dir = dir.split('/'); let root = dir[0]; let directory = dir.slice(1).join("/"); console.log("Sending Upload Request..."); // It might not be a bad idea to validate that this is // a gcode file here, and reject and other files. // If you want to allow multiple selections, the below code should be // done in a loop, and the 'let file' above should be the entire // array of files and not the first element let fdata = new FormData(); fdata.append("file", file); fdata.append("root", root); fdata.append("path", directory); let settings = { url: origin + api.upload.url, data: fdata, cache: false, contentType: false, processData: false, method: 'POST', xhr: () => { let xhr = new window.XMLHttpRequest(); xhr.upload.addEventListener("progress", (evt) => { if (evt.lengthComputable) { update_progress(evt.loaded, evt.total); } }, false); return xhr; }, success: (resp, status) => { console.log(resp); return false; } }; if (apikey != null) settings.headers = {"X-Api-Key": apikey}; $.ajax(settings); $('#upload-file').val(''); } }); // Pause/Resume a currently running print. The specific gcode executed // is configured in printer.cfg. $("#btnpauseresume").click(() =>{ if (api_type == 'http') { let path = paused ? api.resume_print.url : api.pause_print.url form_post_request(path); } else { if (paused) { resume_print(); } else { pause_print(); } } }); // Cancel a currently running print. The specific gcode executed // is configured in printer.cfg. $("#btncancelprint").click(() =>{ if (api_type == 'http') { form_post_request(api.cancel_print.url); } else { cancel_print(); } }); // Refresh File List $("#btngetfiles").click(() =>{ if (api_type == 'http') { form_get_request(api.file_list.url); } else { get_file_list(); } }); $('#btnqueryendstops').click(() => { if (api_type == 'http') { form_get_request(api.query_endstops.url); } else { get_endstops(); } }); // Post Subscription Request $('#btnsubscribe').click(() => { if (api_type == 'http') { let qs = "?gcode_move=gcode_position,speed,speed_factor,extrude_factor" + "&toolhead&virtual_sdcard&heater_bed&extruder=temperature,target&fan&idle_timeout&pause_resume" + "&print_stats"; form_post_request(api.object_subscription.url, qs); } else { const sub = { objects: { gcode_move: ["gcode_position", "speed", "speed_factor", "extrude_factor"], idle_timeout: null, pause_resume: null, toolhead: null, virtual_sdcard: null, heater_bed: null, extruder: ["temperature", "target"], fan: null, print_stats: null} }; add_subscription(sub); } }); $('#btngethelp').click(() => { if (api_type == 'http') { form_get_request(api.gcode_help.url); } else { get_gcode_help(); } }); $('#btngetobjs').click(() => { if (api_type == 'http') { form_get_request(api.object_list.url); } else { get_object_list(); } }); $('#btntestmesh').click(() => { if (api_type == 'http') { let settings = {url: origin + api.object_status.url + "?bed_mesh"}; if (apikey != null) settings.headers = {"X-Api-Key": apikey}; $.get(settings, (resp, status) => { process_mesh(resp.result) return false; }); } else { get_mesh(); } }); $('#btnsendbatch').click(() => { let default_gcs = "M118 This works,RESPOND TYPE=invalid,M118 Execs Despite an Error"; let result = window.prompt("Enter a set of comma separated gcodes:", default_gcs); if (result == null || result == "") { console.log("Batch GCode Send Cancelled"); return; } let gcodes = result.trim().split(','); send_gcode_batch(gcodes); }); $('#btnsendmacro').click(() => { let default_gcs = "M118 This works,RESPOND TYPE=invalid,M118 Should Not Exec"; let result = window.prompt("Enter a set of comma separated gcodes:", default_gcs); if (result == null || result == "") { console.log("Gcode Macro Cancelled"); return; } let gcodes = result.trim().split(','); send_gcode_macro(gcodes); }); $('#btnestop').click(() => { if (api_type == 'http') { form_post_request(api.estop.url); } else { emergency_stop(); } }); $('#btnrestart').click(() => { if (api_type == 'http') { form_post_request(api.restart.url); } else { restart(); } }); $('#btnfirmwarerestart').click(() => { if (api_type == 'http') { form_post_request(api.firmware_restart.url); } else { firmware_restart(); } }); $('#btnreboot').click(() => { if (api_type == 'http') { form_post_request(api.reboot.url); } else { reboot(); } }); $('#btnshutdown').click(() => { if (api_type == 'http') { form_post_request(api.shutdown.url); } else { shutdown(); } }); $('#btngetlog').click(() => { form_download_request(api.klippy_log.url); }); $('#btnmoonlog').click(() => { form_download_request(api.moonraker_log.url); }); check_authorization(); };