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 @@ +
+