From a0aa7060774338d16ac28cd969d31338991f75ce Mon Sep 17 00:00:00 2001 From: Carlos Lugtu Date: Fri, 26 Jun 2026 17:53:20 -0700 Subject: [PATCH] add OctoPrint/Moonraker remote send to laser export, CNCjs bridge tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 'send to host' panel in the laser export modal that posts gcode directly to an OctoPrint or Moonraker endpoint. Enabled via the exportOcto controller setting. Host and API key persist in localStorage. Also adds tools/cncjs-bridge — a zero-dependency Node.js shim that accepts OctoPrint-format multipart uploads from Kiri and forwards them to CNCjs's /api/gcode endpoint, bridging the two without modifying either tool's server. Fixes optional chaining on navigator.serviceWorker in boot path. Co-Authored-By: Claude Sonnet 4.6 --- bin/esbuild.config.mjs | 1 + docs/kiri-moto/integrations.md | 21 ++++++ src/kiri/app/export.js | 44 ++++++++++++ src/main/kiri.js | 2 +- tools/cncjs-bridge/README.md | 34 +++++++++ tools/cncjs-bridge/bridge.js | 123 ++++++++++++++++++++++++++++++++ tools/cncjs-bridge/package.json | 9 +++ web/kiri/index.html | 19 +++++ 8 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 tools/cncjs-bridge/README.md create mode 100644 tools/cncjs-bridge/bridge.js create mode 100644 tools/cncjs-bridge/package.json diff --git a/bin/esbuild.config.mjs b/bin/esbuild.config.mjs index 11b24998a..4f1ccff91 100644 --- a/bin/esbuild.config.mjs +++ b/bin/esbuild.config.mjs @@ -77,6 +77,7 @@ const rec = { define: { 'process.env.NODE_ENV': `"${mode}"` }, external: [ 'module', + 'node:module', './constants', './voronoi_structures', './voronoi_ctypes', diff --git a/docs/kiri-moto/integrations.md b/docs/kiri-moto/integrations.md index 64147ec9d..d94236a79 100644 --- a/docs/kiri-moto/integrations.md +++ b/docs/kiri-moto/integrations.md @@ -26,6 +26,27 @@ Kiri:Moto is the slicing engine behind [SimplyPrint's](https://simplyprint.io/) [CAMLab](http://camlab.sienci.com/camlab) is a fork of an earlier version of Kiri:Moto. +## CNCjs + +[CNCjs](https://cncjs.io/) is a web-based controller for GRBL and other CNC firmware. Kiri:Moto's laser mode can send gcode directly to CNCjs using the `tools/cncjs-bridge` shim included in this repository. + +The bridge is a zero-dependency Node.js server that accepts Kiri's OctoPrint-format uploads and forwards them to CNCjs's `/api/gcode` endpoint. + +### Setup + +``` +cd tools/cncjs-bridge +node bridge.js +``` + +The bridge listens on port **5310** by default. In Kiri:Moto's laser export dialog, enable the **exportOcto** controller setting, then set the host to `http://localhost:5310` with no API key. + +In the laser device settings, configure the laser on/off commands for GRBL: +- **Laser On**: `M3 S{power}` +- **Laser Off**: `M5` + +Enable GRBL laser mode on your controller once with `$32=1` via the CNCjs console. + ## Thingiverse Like Onshape, Kiri:Moto is integrated as a native [Thing-app](https://www.thingiverse.com/apps/kirimoto) into Thingiverse. As a user of Thingiverse, you can elect to have Kiri:Moto show up as a "way to open" a Thing file. This provides the convenience of directly accessing, slicing, and printing parts on Thingiverse without first having to download them and then re-import them into a slicer. diff --git a/src/kiri/app/export.js b/src/kiri/app/export.js index b1e5556fa..7d9a68f13 100644 --- a/src/kiri/app/export.js +++ b/src/kiri/app/export.js @@ -180,6 +180,50 @@ function exportLaserDialog(data, names) { $('print-dxf').onclick = download_dxf; $('print-obj').onclick = download_obj; $('print-lg').onclick = download_gcode; + + const showRemote = settings.controller.exportOcto; + $('laser-remote-head').style.display = showRemote ? '' : 'none'; + $('laser-remote-send').style.display = showRemote ? '' : 'none'; + + if (showRemote) { + const hostEl = $('laser-octo-host'); + const apikEl = $('laser-octo-apik'); + const typeEl = $('laser-octo-type'); + hostEl.value = localGet('octo-host') || ''; + apikEl.value = localGet('octo-apik') || ''; + typeEl.value = localGet('octo-type') || 'octoprint'; + + $('laser-send-remote').onclick = function() { + const host = hostEl.value.trim(); + const apik = apikEl.value.trim(); + const type = typeEl.value; + if (!host) { api.show.alert('host is required'); return; } + localSet('octo-host', host); + localSet('octo-apik', apik); + localSet('octo-type', type); + + const gcode = driver.exportGCode(settings, data); + const fname = $('print-filename-laser').value + '.gcode'; + const form = new FormData(); + form.append('file', new Blob([gcode], { type: 'text/plain' }), fname); + + const endpoint = type === 'moonraker' ? '/server/files/upload' : '/api/files/local'; + const xhr = new XMLHttpRequest(); + xhr.open('POST', host + endpoint); + if (apik) xhr.setRequestHeader('X-Api-Key', apik); + xhr.onreadystatechange = function() { + if (xhr.readyState === 4) { + if (xhr.status >= 200 && xhr.status < 300) { + api.show.alert('sent to ' + host); + api.modal.hide(); + } else { + api.show.alert('send failed: ' + xhr.status + ' ' + xhr.responseText); + } + } + }; + xhr.send(form); + }; + } } /** diff --git a/src/main/kiri.js b/src/main/kiri.js index 8691ecda2..f171c4748 100644 --- a/src/main/kiri.js +++ b/src/main/kiri.js @@ -30,7 +30,7 @@ function safeExec(fn, name) { async function checkReady() { if (document.readyState === 'complete') { - let bootctrl = navigator.serviceWorker.controller; + let bootctrl = navigator.serviceWorker?.controller; console.log(`kiri | boot ctrl | ` + (bootctrl ? true : false)); kiri.api = api; self.$ = api.web.$; diff --git a/tools/cncjs-bridge/README.md b/tools/cncjs-bridge/README.md new file mode 100644 index 000000000..6ca6801b7 --- /dev/null +++ b/tools/cncjs-bridge/README.md @@ -0,0 +1,34 @@ +# kiri-cncjs-bridge + +A zero-dependency Node.js shim that lets Kiri:Moto's laser export send gcode directly to [CNCjs](https://cncjs.io/). + +Kiri's "send to host" export speaks OctoPrint's multipart upload format. CNCjs has its own REST API. This bridge accepts the OctoPrint-format POST from Kiri and forwards the gcode to CNCjs's `/api/gcode` endpoint. + +## Requirements + +- Node.js 18+ +- CNCjs running and connected to your machine (default: `http://localhost:8000`) + +## Usage + +``` +node bridge.js +``` + +The bridge listens on port **5310**. Leave it running while you work. + +## Kiri:Moto setup + +1. In Preferences, enable **exportOcto** +2. In the laser export dialog, set: + - **host**: `http://localhost:5310` + - **api key**: _(leave blank)_ + - **type**: `octoprint` + +## Laser device setup (GRBL) + +In your Kiri laser device settings, set: +- **Laser On**: `M3 S{power}` +- **Laser Off**: `M5` + +Send `$32=1` once from the CNCjs console to enable GRBL laser mode. diff --git a/tools/cncjs-bridge/bridge.js b/tools/cncjs-bridge/bridge.js new file mode 100644 index 000000000..e9d9212c6 --- /dev/null +++ b/tools/cncjs-bridge/bridge.js @@ -0,0 +1,123 @@ +#!/usr/bin/env node +// Bridges Kiri:Moto's OctoPrint-format file send to CNCjs's /api/gcode endpoint. +// No external dependencies — uses only Node.js built-ins. + +const http = require('http'); + +const CNCJS = 'http://127.0.0.1:8000'; +const PORT = 5310; + +let cachedToken = null; + +// Parse multipart/form-data body, return { filename, content } for the first file field +function parseMultipart(body, contentType) { + const match = contentType.match(/boundary=([^\s;]+)/); + if (!match) throw new Error('no boundary in content-type'); + const boundary = '--' + match[1]; + const parts = body.split(boundary).slice(1); // skip preamble + for (const part of parts) { + if (part.startsWith('--')) break; // epilogue + const split = part.indexOf('\r\n\r\n'); + if (split === -1) continue; + const headers = part.slice(0, split); + // strip leading \r\n and trailing \r\n before next boundary + const content = part.slice(split + 4, part.lastIndexOf('\r\n')); + const dispMatch = headers.match(/Content-Disposition:[^\r\n]*name="file"[^\r\n]*/i); + if (!dispMatch) continue; + const fnMatch = headers.match(/filename="([^"]+)"/i); + return { filename: fnMatch ? fnMatch[1] : 'kiri.gcode', content }; + } + throw new Error('no file field in multipart body'); +} + +function post(url, body, headers = {}) { + return new Promise((resolve, reject) => { + const buf = Buffer.isBuffer(body) ? body : Buffer.from(body); + const u = new URL(url); + const req = http.request({ + hostname: u.hostname, port: u.port || 80, path: u.pathname, + method: 'POST', + headers: { 'Content-Length': buf.length, ...headers } + }, res => { + const chunks = []; + res.on('data', c => chunks.push(c)); + res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString() })); + }); + req.on('error', reject); + req.write(buf); + req.end(); + }); +} + +function get(url, headers = {}) { + return new Promise((resolve, reject) => { + const u = new URL(url); + http.get({ hostname: u.hostname, port: u.port || 80, path: u.pathname, headers }, res => { + const chunks = []; + res.on('data', c => chunks.push(c)); + res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString() })); + }).on('error', reject); + }); +} + +async function getToken() { + if (cachedToken) return cachedToken; + const res = await post(`${CNCJS}/api/signin`, JSON.stringify({ token: '' }), { + 'Content-Type': 'application/json' + }); + cachedToken = JSON.parse(res.body).token; + return cachedToken; +} + +async function getPort(token) { + const res = await get(`${CNCJS}/api/controllers`, { Authorization: `Bearer ${token}` }); + const data = JSON.parse(res.body); + const ports = Array.isArray(data) ? data.map(c => c.port) : Object.keys(data); + return ports.find(Boolean) || null; +} + +function cors(res) { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Api-Key, Authorization'); +} + +const server = http.createServer((req, res) => { + cors(res); + if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; } + + const isUpload = req.url === '/api/files/local' || req.url === '/server/files/upload'; + if (req.method !== 'POST' || !isUpload) { + res.writeHead(404); res.end('not found'); return; + } + + const chunks = []; + req.on('data', c => chunks.push(c)); + req.on('end', async () => { + try { + const body = Buffer.concat(chunks).toString('binary'); + const { filename, content } = parseMultipart(body, req.headers['content-type'] || ''); + const token = await getToken(); + const port = await getPort(token); + if (!port) { + res.writeHead(503); + res.end(JSON.stringify({ error: 'No connected CNCjs controller — connect to a machine in CNCjs first.' })); + return; + } + const payload = JSON.stringify({ port, name: filename, gcode: content }); + const result = await post(`${CNCJS}/api/gcode`, payload, { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }); + console.log(`[bridge] sent ${filename} to CNCjs port ${port} → ${result.status}`); + res.writeHead(result.status < 300 ? 200 : result.status); + res.end(result.body); + } catch (e) { + console.error('[bridge] error:', e.message); + cachedToken = null; + res.writeHead(500); res.end(JSON.stringify({ error: e.message })); + } + }); +}); + +server.listen(PORT, () => console.log(`[bridge] kiri→cncjs bridge listening on :${PORT}`)); diff --git a/tools/cncjs-bridge/package.json b/tools/cncjs-bridge/package.json new file mode 100644 index 000000000..58f01d542 --- /dev/null +++ b/tools/cncjs-bridge/package.json @@ -0,0 +1,9 @@ +{ + "name": "kiri-cncjs-bridge", + "version": "1.0.0", + "description": "OctoPrint-format shim that forwards Kiri:Moto laser gcode to CNCjs", + "main": "bridge.js", + "scripts": { + "start": "node bridge.js" + } +} diff --git a/web/kiri/index.html b/web/kiri/index.html index ed3866daf..d08546e28 100644 --- a/web/kiri/index.html +++ b/web/kiri/index.html @@ -435,6 +435,25 @@ +
+
+
+ + +
+
+ + +
+
+ + +
+ +