bambuddy_adapter.js 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877
  1. /**
  2. * bambuddy_adapter.js
  3. * Bridges OctoPrint-PrettyGCode to Bambuddy's API.
  4. *
  5. * Load this BEFORE prettygcode.js. It provides:
  6. * - OCTOPRINT_VIEWMODELS shim
  7. * - Minimal KnockoutJS observable shim (ko.observable)
  8. * - fetch() + XHR interceptors for path rewriting
  9. * - Bambuddy WebSocket → fromCurrentData bridge
  10. * - File picker backed by Bambuddy's library API
  11. * - Settings load/save via plugin settings endpoint
  12. *
  13. * What works:
  14. * - Full 3D GCode visualisation
  15. * - Dark mode and all dat.GUI settings
  16. * - File selection from Bambuddy's file library
  17. * - Print progress highlight (% based)
  18. * - Auto-load currently printing file
  19. *
  20. * What doesn't work (Bambu hardware limitation):
  21. * - Live nozzle animation during printing — Bambu printers do not expose
  22. * GCode serial echo logs (Send: G1 X...), so PrintHeadSimulator has no input.
  23. */
  24. (function () {
  25. 'use strict';
  26. const API_BASE = '/api/v1';
  27. const VIEWER_BASE = '/gcode-viewer'; // static assets now served from here
  28. // -------------------------------------------------------------------------
  29. // Auth helper
  30. // -------------------------------------------------------------------------
  31. function authHeaders() {
  32. // sessionStorage is used when the user opts out of "remember me";
  33. // fall back to localStorage for persistent sessions.
  34. const token = sessionStorage.getItem('auth_token') ?? localStorage.getItem('auth_token');
  35. return token ? { Authorization: 'Bearer ' + token } : {};
  36. }
  37. // When auth is enabled and the user has no valid token, every API call
  38. // returns 401 and the viewer chrome stays on screen showing empty state.
  39. // Intercept the first 401 and hand control back to the SPA, which owns
  40. // the login flow and will redirect to /login when appropriate.
  41. let _authRedirectFired = false;
  42. function apiFetch(path, opts) {
  43. return fetch(API_BASE + path, {
  44. ...opts,
  45. headers: { ...authHeaders(), ...(opts && opts.headers) },
  46. cache: 'no-store',
  47. }).then((response) => {
  48. if (response.status === 401 && !_authRedirectFired) {
  49. _authRedirectFired = true;
  50. try {
  51. sessionStorage.removeItem('auth_token');
  52. localStorage.removeItem('auth_token');
  53. } catch (e) { /* storage unavailable */ }
  54. window.top.location.replace('/');
  55. }
  56. return response;
  57. });
  58. }
  59. // -------------------------------------------------------------------------
  60. // 1. Minimal KnockoutJS shim (ko.observable / ko.computed)
  61. // -------------------------------------------------------------------------
  62. window.ko = {
  63. observable: function (initial) {
  64. var _val = initial;
  65. var _subs = [];
  66. var obs = function (newVal) {
  67. if (arguments.length > 0) {
  68. _val = newVal;
  69. _subs.forEach(function (cb) { try { cb(newVal); } catch (e) {} });
  70. }
  71. return _val;
  72. };
  73. obs.subscribe = function (cb) {
  74. _subs.push(cb);
  75. return { dispose: function () { _subs = _subs.filter(function (s) { return s !== cb; }); } };
  76. };
  77. obs.peek = function () { return _val; };
  78. return obs;
  79. },
  80. computed: function (fn) {
  81. var obs = window.ko.observable(null);
  82. try { obs(fn()); } catch (e) {}
  83. return obs;
  84. },
  85. pureComputed: function (fn) { return window.ko.computed(fn); },
  86. mapping: { fromJS: function (obj) { return obj; } },
  87. };
  88. // -------------------------------------------------------------------------
  89. // 2. OCTOPRINT_VIEWMODELS registration shim
  90. // -------------------------------------------------------------------------
  91. window.OCTOPRINT_VIEWMODELS = [];
  92. // -------------------------------------------------------------------------
  93. // 3. Fake OctoPrint settings / printer profile / login viewmodels
  94. // -------------------------------------------------------------------------
  95. var fakeSettings = {
  96. webcam: {
  97. streamUrl: ko.observable(''),
  98. flipH: ko.observable(false),
  99. flipV: ko.observable(false),
  100. rotate90: ko.observable(false),
  101. },
  102. plugins: {
  103. prettygcode: {
  104. darkMode: ko.observable(false),
  105. },
  106. },
  107. };
  108. // Bed sizes for common Bambu models (mm)
  109. var BAMBU_BED_SIZES = {
  110. 'X1': { width: 256, depth: 256, height: 256 },
  111. 'X1C': { width: 256, depth: 256, height: 256 },
  112. 'X1E': { width: 256, depth: 256, height: 256 },
  113. 'P1S': { width: 256, depth: 256, height: 256 },
  114. 'P1P': { width: 256, depth: 256, height: 256 },
  115. 'A1': { width: 300, depth: 300, height: 300 },
  116. 'A1 Mini': { width: 180, depth: 180, height: 180 },
  117. };
  118. var DEFAULT_BED = { width: 256, depth: 256, height: 256 };
  119. var currentBed = Object.assign({}, DEFAULT_BED);
  120. function makeFakeProfileData(bed) {
  121. return {
  122. volume: {
  123. width: ko.observable(bed.width),
  124. depth: ko.observable(bed.depth),
  125. height: ko.observable(bed.height),
  126. origin: ko.observable('lowerleft'),
  127. formFactor: ko.observable('rectangular'),
  128. // Make custom_box a function so prettygcode.js uses width()/depth()/height()
  129. custom_box: function () { return false; },
  130. },
  131. };
  132. }
  133. var fakePrinterProfiles = {
  134. currentProfileData: ko.observable(makeFakeProfileData(currentBed)),
  135. };
  136. var fakeLoginState = {
  137. isUser: ko.observable(true),
  138. isAdmin: ko.observable(false),
  139. };
  140. var fakeControl = {};
  141. // -------------------------------------------------------------------------
  142. // 4. fetch() interceptor — rewrite OctoPrint paths to Bambuddy
  143. // -------------------------------------------------------------------------
  144. var _originalFetch = window.fetch.bind(window);
  145. window.fetch = function (resource, init) {
  146. var url = (typeof resource === 'string') ? resource
  147. : (resource && resource.url) ? resource.url
  148. : null;
  149. if (url) {
  150. // Normalize: strip scheme+host so regexes work on the path regardless
  151. // of whether the browser resolved a relative URL to absolute.
  152. var path = url.replace(/^https?:\/\/[^\/]+/, '');
  153. // Also strip the viewer's own path prefix — the browser resolves relative URLs
  154. // like 'downloads/files/local/...' to '/gcode-viewer/downloads/...' because
  155. // the page is served from /gcode-viewer/. The regexes below expect bare paths.
  156. path = path.replace(/^\/gcode-viewer(?=\/|$)/, '');
  157. var newPath = path;
  158. // OctoPrint file download → Bambuddy library download
  159. newPath = newPath.replace(
  160. /^\/?downloads\/files\/local\/__bambuddy_file_(\d+)$/,
  161. API_BASE + '/library/files/$1/download'
  162. );
  163. // OctoPrint plugin static assets → gcode-viewer static files
  164. newPath = newPath.replace(
  165. /^\/?plugin\/prettygcode\/static\//,
  166. VIEWER_BASE + '/'
  167. );
  168. if (newPath !== path) {
  169. url = newPath;
  170. resource = url; // always pass as string after rewriting
  171. }
  172. // Inject auth header for all Bambuddy API calls
  173. if (url.startsWith(API_BASE)) {
  174. var hdrs = authHeaders();
  175. init = init || {};
  176. init.headers = Object.assign({}, hdrs, init.headers || {});
  177. }
  178. }
  179. var promise = _originalFetch(resource, init);
  180. // Tee GCode downloads to build the layer map for sync + nozzle animation
  181. if (url && url.match(/\/library\/files\/\d+\/download/)) {
  182. promise = promise.then(function (response) {
  183. var clone = response.clone();
  184. clone.text().then(function (text) {
  185. gcodeLayerMap = parseGcodeLayerMap(text);
  186. lastFedLayer = -1;
  187. console.log('[PrettyGCode] Parsed ' + gcodeLayerMap.layerOffsets.length +
  188. ' layers for sync (' + Math.round(gcodeLayerMap.totalBytes / 1024) + ' KB)');
  189. }).catch(function (e) {
  190. console.warn('[PrettyGCode] GCode layer parse failed:', e);
  191. });
  192. return response;
  193. });
  194. }
  195. return promise;
  196. };
  197. // -------------------------------------------------------------------------
  198. // 5. XHR interceptor — rewrite OctoPrint paths (used by THREE.OBJLoader etc.)
  199. // -------------------------------------------------------------------------
  200. var _origXHROpen = XMLHttpRequest.prototype.open;
  201. XMLHttpRequest.prototype.open = function (method, url) {
  202. if (typeof url === 'string') {
  203. // Strip host if absolute, then rewrite OctoPrint static asset paths
  204. var path = url.replace(/^https?:\/\/[^\/]+/, '');
  205. path = path.replace(/^\/?plugin\/prettygcode\/static\//, VIEWER_BASE + '/');
  206. url = path;
  207. }
  208. var args = Array.prototype.slice.call(arguments);
  209. args[1] = url;
  210. return _origXHROpen.apply(this, args);
  211. };
  212. // -------------------------------------------------------------------------
  213. // 6. GCode layer parser
  214. //
  215. // Builds a layer map from the raw GCode text so we can:
  216. // a) Map layer_num → byte offset in file (drives prettygcode's filepos sync
  217. // and layer highlight, same as if OctoPrint were reporting filepos)
  218. // b) Extract a set of G0/G1 commands per layer to feed the PrintHeadSimulator
  219. // as synthetic "Send: G1 X... Y... Z..." entries, animating the nozzle model.
  220. //
  221. // Layer detection mirrors prettygcode.js: a new layer starts on the first extrusion
  222. // at a Z position we haven't extruded at before.
  223. // -------------------------------------------------------------------------
  224. function parseGcodeLayerMap(text) {
  225. var lines = text.split('\n');
  226. var layerOffsets = []; // layerOffsets[i] = byte pos in file where layer i starts
  227. var layerCmds = []; // layerCmds[i] = array of ' G1 X... Y... Z...' strings
  228. var byteOffset = 0;
  229. var x = 0, y = 0, z = 0, e = 0;
  230. var relative = false, relativeE = false;
  231. var currentLayerZ = null;
  232. var curCmds = [];
  233. for (var i = 0; i < lines.length; i++) {
  234. var raw = lines[i];
  235. // +1 for the \n that was consumed by split
  236. var lineBytes = raw.length + 1;
  237. var cmd = raw.replace(/;.*$/, '').trim();
  238. if (!cmd) { byteOffset += lineBytes; continue; }
  239. var parts = cmd.split(/\s+/);
  240. var g = parts[0].toUpperCase();
  241. if (g === 'G90') { relative = false; relativeE = false; }
  242. else if (g === 'G91') { relative = true; relativeE = true; }
  243. else if (g === 'M82') { relativeE = false; }
  244. else if (g === 'M83') { relativeE = true; }
  245. else if (g === 'G92') {
  246. // coordinate reset
  247. for (var p = 1; p < parts.length; p++) {
  248. var k0 = parts[p][0].toUpperCase();
  249. var v0 = parseFloat(parts[p].slice(1));
  250. if (!isNaN(v0)) {
  251. if (k0 === 'X') x = v0;
  252. else if (k0 === 'Y') y = v0;
  253. else if (k0 === 'Z') z = v0;
  254. else if (k0 === 'E') e = v0;
  255. }
  256. }
  257. } else if (g === 'G0' || g === 'G1') {
  258. var nx = x, ny = y, nz = z, ne = e;
  259. var hasE = false;
  260. for (var p = 1; p < parts.length; p++) {
  261. if (!parts[p]) continue;
  262. var k1 = parts[p][0].toUpperCase();
  263. var v1 = parseFloat(parts[p].slice(1));
  264. if (isNaN(v1)) continue;
  265. if (k1 === 'X') nx = relative ? x + v1 : v1;
  266. else if (k1 === 'Y') ny = relative ? y + v1 : v1;
  267. else if (k1 === 'Z') nz = relative ? z + v1 : v1;
  268. else if (k1 === 'E') { ne = relativeE ? e + v1 : v1; hasE = true; }
  269. }
  270. // New layer: first extrusion at a new Z (same logic as prettygcode.js)
  271. if (hasE && ne > e && nz !== currentLayerZ) {
  272. currentLayerZ = nz;
  273. if (curCmds.length > 0) layerCmds.push(curCmds);
  274. else if (layerOffsets.length > 0) layerCmds.push([]); // gap layer
  275. curCmds = [];
  276. layerOffsets.push(byteOffset);
  277. }
  278. // Record movement commands for nozzle sim (keep arrays small — max 500/layer)
  279. if ((hasE || nz !== z) && curCmds.length < 500) {
  280. curCmds.push(' G1 X' + nx.toFixed(3) +
  281. ' Y' + ny.toFixed(3) +
  282. ' Z' + nz.toFixed(3));
  283. }
  284. x = nx; y = ny; z = nz; e = ne;
  285. }
  286. byteOffset += lineBytes;
  287. }
  288. if (curCmds.length > 0) layerCmds.push(curCmds);
  289. return {
  290. layerOffsets: layerOffsets,
  291. layerCmds: layerCmds,
  292. totalBytes: byteOffset,
  293. };
  294. }
  295. // -------------------------------------------------------------------------
  296. // 8. State
  297. // -------------------------------------------------------------------------
  298. var viewModel = null;
  299. var currentFileId = null;
  300. var currentFilename = null;
  301. var currentFileDate = 0; // stable epoch — only changes when a new file is loaded
  302. var ws = null;
  303. var wsReconnectTimer = null;
  304. var printers = []; // [{id, name, model, state, progress, subtask_name}]
  305. var selectedPrinterId = null;
  306. var gcodeLayerMap = null; // parsed layer data: {layerOffsets, layerCmds, totalBytes}
  307. var lastFedLayer = -1; // last layer_num whose commands we fed to printHeadSim
  308. // -------------------------------------------------------------------------
  309. // 9. Bambuddy WebSocket
  310. // -------------------------------------------------------------------------
  311. function connectWebSocket() {
  312. var token = localStorage.getItem('auth_token');
  313. var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
  314. // Do NOT put the token in the URL — it would appear in server logs.
  315. // The WebSocket endpoint is currently unauthenticated server-side;
  316. // all sensitive calls go through authenticated fetch() instead.
  317. var wsUrl = proto + '//' + location.host + API_BASE + '/ws';
  318. ws = new WebSocket(wsUrl);
  319. ws.onopen = function () {
  320. console.log('[PrettyGCode] Connected to Bambuddy WebSocket');
  321. };
  322. ws.onmessage = function (event) {
  323. try {
  324. var msg = JSON.parse(event.data);
  325. if (msg.type === 'printer_status') {
  326. handlePrinterStatus(msg.printer_id, msg.data);
  327. }
  328. } catch (e) {}
  329. };
  330. ws.onclose = function () {
  331. clearTimeout(wsReconnectTimer);
  332. wsReconnectTimer = setTimeout(connectWebSocket, 3000);
  333. };
  334. ws.onerror = function () {
  335. ws.close();
  336. };
  337. }
  338. function bambuStateToOctoState(bambuState) {
  339. var map = {
  340. RUNNING: 'Printing',
  341. PAUSE: 'Paused',
  342. FAILED: 'Error',
  343. FINISH: 'Operational',
  344. IDLE: 'Operational',
  345. };
  346. return map[bambuState] || 'Operational';
  347. }
  348. function handlePrinterStatus(printerId, data) {
  349. // Update printer list entry
  350. var found = false;
  351. for (var i = 0; i < printers.length; i++) {
  352. if (printers[i].id === printerId) {
  353. // Allowlist to prevent prototype pollution from crafted WS messages
  354. var allowed2 = ['name', 'state', 'progress', 'layer_num', 'subtask_name', 'gcode_file', 'camera_url', 'model'];
  355. allowed2.forEach(function (k) { if (k in data) printers[i][k] = data[k]; });
  356. found = true;
  357. break;
  358. }
  359. }
  360. if (!found) {
  361. // Only copy known, safe keys — avoids prototype pollution from a crafted WS message
  362. var allowed = ['name', 'state', 'progress', 'layer_num', 'subtask_name', 'gcode_file', 'camera_url', 'model'];
  363. var entry = { id: printerId };
  364. allowed.forEach(function (k) { if (k in data) entry[k] = data[k]; });
  365. printers.push(entry);
  366. }
  367. updatePrinterSelector();
  368. // Only feed data for the selected printer
  369. if (selectedPrinterId !== null && printerId !== selectedPrinterId) return;
  370. if (selectedPrinterId === null && printers.length > 0) {
  371. selectedPrinterId = printers[0].id;
  372. }
  373. if (!viewModel) return;
  374. var printer = null;
  375. for (var j = 0; j < printers.length; j++) {
  376. if (printers[j].id === printerId) { printer = printers[j]; break; }
  377. }
  378. if (!printer) return;
  379. // Update bed size from printer model
  380. var bedKey = (printer.model || '').toUpperCase();
  381. for (var modelName in BAMBU_BED_SIZES) {
  382. if (bedKey.indexOf(modelName.toUpperCase()) !== -1) {
  383. currentBed = BAMBU_BED_SIZES[modelName];
  384. break;
  385. }
  386. }
  387. // Replace the entire profile data so the subscribe() fires
  388. fakePrinterProfiles.currentProfileData(makeFakeProfileData(currentBed));
  389. // Auto-load currently printing file if it changed
  390. var subtask = printer.subtask_name || printer.gcode_file || '';
  391. if (subtask && subtask !== currentFilename) {
  392. currentFilename = subtask;
  393. tryAutoLoadPrintingFile(subtask);
  394. }
  395. // Update webcam URL
  396. if (printer.camera_url) {
  397. fakeSettings.webcam.streamUrl(printer.camera_url);
  398. }
  399. feedCurrentData(printer);
  400. }
  401. function feedCurrentData(printer) {
  402. if (!viewModel || !viewModel.fromCurrentData) return;
  403. var octoState = bambuStateToOctoState(printer.state || 'IDLE');
  404. var isPrinting = octoState === 'Printing' || octoState === 'Paused';
  405. // --- Layer sync via filepos -------------------------------------------
  406. // prettygcode.js calls gcodeProxy.syncGcodeObjToFilePos(curPrintFilePos) each
  407. // animation frame when printing + syncToProgress is on. Pass the byte offset
  408. // of the current layer so the highlight advances correctly.
  409. var filepos = null;
  410. var logs = [];
  411. if (gcodeLayerMap && isPrinting) {
  412. // Bambu layer_num is 1-based; our layerOffsets array is 0-based.
  413. var layerIdx = Math.max(0, (printer.layer_num || 1) - 1);
  414. layerIdx = Math.min(layerIdx, gcodeLayerMap.layerOffsets.length - 1);
  415. filepos = gcodeLayerMap.layerOffsets[layerIdx] || 0;
  416. // --- Nozzle animation via synthetic Send: commands -------------------
  417. // PrintHeadSimulator.addCommand() expects "Send: G1 X... Y... Z..." entries.
  418. // Feed the movement commands for the current layer once per layer change.
  419. // The simulator interpolates them over real time, animating the nozzle model.
  420. if (layerIdx !== lastFedLayer && gcodeLayerMap.layerCmds[layerIdx]) {
  421. lastFedLayer = layerIdx;
  422. var cmds = gcodeLayerMap.layerCmds[layerIdx];
  423. // PrintHeadSimulator buffer is capped at 1000; feed at most 400 commands
  424. // so there's room for the sim to drain before more arrive.
  425. logs = cmds.slice(0, 400).map(function (c) { return 'Send:' + c; });
  426. }
  427. }
  428. viewModel.fromCurrentData({
  429. job: {
  430. file: {
  431. path: currentFileId ? ('__bambuddy_file_' + currentFileId) : null,
  432. date: currentFileDate,
  433. },
  434. estimatedPrintTime: null,
  435. },
  436. state: {
  437. text: octoState,
  438. flags: { printing: octoState === 'Printing', paused: octoState === 'Paused' },
  439. },
  440. progress: {
  441. filepos: filepos,
  442. completion: (printer.progress || 0) / 100,
  443. printTime: null,
  444. },
  445. currentZ: null,
  446. logs: logs,
  447. });
  448. }
  449. // -------------------------------------------------------------------------
  450. // 8. Auto-load file when printer starts printing
  451. // -------------------------------------------------------------------------
  452. function tryAutoLoadPrintingFile(filename) {
  453. // Search the library for a matching .gcode file
  454. apiFetch('/library/files?sort_by=updated_at&sort_dir=desc', {})
  455. .then(function (r) { return r.json(); })
  456. .then(function (files) {
  457. if (!Array.isArray(files)) return;
  458. var match = files.find(function (f) {
  459. return f.filename === filename ||
  460. f.filename === filename + '.gcode' ||
  461. f.filename.replace(/\.gcode$/, '') === filename.replace(/\.gcode$/, '');
  462. });
  463. if (match) loadFileById(match.id, match.filename, match.file_size);
  464. })
  465. .catch(function () {});
  466. }
  467. // -------------------------------------------------------------------------
  468. // 9. File loading
  469. // -------------------------------------------------------------------------
  470. function loadFileById(fileId, filename, fileSize) {
  471. currentFileId = fileId;
  472. currentFilename = filename;
  473. currentFileDate = Date.now(); // new stable date so prettygcode loads exactly once
  474. gcodeLayerMap = null; // cleared here; re-populated when fetch() intercept fires
  475. lastFedLayer = -1;
  476. stopPlayback(true);
  477. updateFilenameDisplay(filename);
  478. // Enable play button once a file is loaded
  479. var playBtn = document.getElementById('bb-play-btn');
  480. if (playBtn) playBtn.disabled = false;
  481. // Trigger prettygcode.js's updateJob — date must match currentFileDate exactly
  482. // so subsequent feedCurrentData calls don't re-trigger the download
  483. if (viewModel && viewModel.fromCurrentData) {
  484. viewModel.fromCurrentData({
  485. job: {
  486. file: {
  487. path: '__bambuddy_file_' + fileId,
  488. date: currentFileDate,
  489. },
  490. estimatedPrintTime: null,
  491. },
  492. state: { text: 'Operational', flags: { printing: false } },
  493. progress: { filepos: null, completion: 0 },
  494. currentZ: null,
  495. logs: [],
  496. });
  497. }
  498. }
  499. function updateFilenameDisplay(filename) {
  500. var el = document.getElementById('bb-current-file');
  501. if (el) el.textContent = filename || '— no file loaded —';
  502. }
  503. // -------------------------------------------------------------------------
  504. // 10. File picker
  505. // -------------------------------------------------------------------------
  506. function buildFilePicker() {
  507. var container = document.getElementById('bb-file-picker');
  508. if (!container) return;
  509. var input = document.createElement('input');
  510. input.type = 'text';
  511. input.placeholder = 'Search .gcode files…';
  512. input.className = 'bb-search';
  513. input.style.cssText = 'width:100%;padding:4px 8px;background:#333;border:1px solid #555;color:#fff;border-radius:4px;margin-bottom:4px;box-sizing:border-box;';
  514. var list = document.createElement('div');
  515. list.style.cssText = 'max-height:180px;overflow-y:auto;';
  516. container.appendChild(input);
  517. container.appendChild(list);
  518. var allFiles = [];
  519. function render(files) {
  520. list.innerHTML = '';
  521. if (!files.length) {
  522. list.innerHTML = '<div style="color:#888;padding:4px 6px;font-size:12px;">No .gcode files found in library</div>';
  523. return;
  524. }
  525. files.forEach(function (f) {
  526. var row = document.createElement('div');
  527. row.textContent = f.filename;
  528. row.title = f.filename;
  529. row.style.cssText = 'padding:4px 6px;cursor:pointer;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;border-radius:3px;';
  530. row.addEventListener('mouseenter', function () { row.style.background = '#444'; });
  531. row.addEventListener('mouseleave', function () { row.style.background = ''; });
  532. row.addEventListener('click', function () {
  533. loadFileById(f.id, f.filename, f.file_size);
  534. // Close picker
  535. container.classList.toggle('bb-open', false);
  536. });
  537. list.appendChild(row);
  538. });
  539. }
  540. function loadFiles() {
  541. list.innerHTML = '<div style="color:#aaa;padding:4px 6px;font-size:12px;">Loading files…</div>';
  542. // include_root=false returns files from ALL folders, not just root level
  543. apiFetch('/library/files?include_root=false', {})
  544. .then(function (r) { return r.json(); })
  545. .then(function (files) {
  546. if (!Array.isArray(files)) {
  547. list.innerHTML = '<div style="color:#f88;padding:4px 6px;font-size:12px;">Failed to load files</div>';
  548. return;
  549. }
  550. allFiles = files.filter(function (f) {
  551. return f.filename && f.filename.toLowerCase().endsWith('.gcode');
  552. });
  553. render(allFiles);
  554. })
  555. .catch(function () {
  556. list.innerHTML = '<div style="color:#f88;padding:4px 6px;font-size:12px;">Failed to load files — check auth token</div>';
  557. });
  558. }
  559. input.addEventListener('input', function () {
  560. var q = input.value.toLowerCase();
  561. render(q ? allFiles.filter(function (f) { return f.filename.toLowerCase().indexOf(q) !== -1; }) : allFiles);
  562. });
  563. loadFiles();
  564. }
  565. // -------------------------------------------------------------------------
  566. // 11. Printer selector
  567. // -------------------------------------------------------------------------
  568. function updatePrinterSelector() {
  569. var sel = document.getElementById('bb-printer-select');
  570. if (!sel) return;
  571. var current = sel.value;
  572. sel.innerHTML = '';
  573. printers.forEach(function (p) {
  574. var opt = document.createElement('option');
  575. opt.value = p.id;
  576. opt.textContent = (p.name || ('Printer ' + p.id)) + (p.state ? ' [' + p.state + ']' : '');
  577. sel.appendChild(opt);
  578. });
  579. if (current) sel.value = current;
  580. if (!sel.value && printers.length) {
  581. sel.value = printers[0].id;
  582. selectedPrinterId = printers[0].id;
  583. }
  584. }
  585. // -------------------------------------------------------------------------
  586. // 13. Initialise after DOM + scripts are ready
  587. // -------------------------------------------------------------------------
  588. function init() {
  589. // Find the ViewModel registration that prettygcode.js pushed
  590. var reg = null;
  591. for (var i = 0; i < window.OCTOPRINT_VIEWMODELS.length; i++) {
  592. if (window.OCTOPRINT_VIEWMODELS[i].construct) {
  593. reg = window.OCTOPRINT_VIEWMODELS[i];
  594. break;
  595. }
  596. }
  597. if (!reg) {
  598. console.error('[PrettyGCode] No ViewModel found in OCTOPRINT_VIEWMODELS');
  599. return;
  600. }
  601. try {
  602. viewModel = new reg.construct([
  603. fakeSettings,
  604. fakeLoginState,
  605. fakePrinterProfiles,
  606. fakeControl,
  607. ]);
  608. } catch (e) {
  609. console.error('[PrettyGCode] ViewModel constructor failed:', e);
  610. return;
  611. }
  612. if (viewModel.onAfterBinding) {
  613. try { viewModel.onAfterBinding(); } catch (e) {}
  614. }
  615. // Trigger tab activation — this calls onTabChange which initialises the Three.js scene
  616. if (viewModel.onTabChange) {
  617. try { viewModel.onTabChange('#tab_plugin_prettygcode', ''); } catch (e) {
  618. console.error('[PrettyGCode] onTabChange failed:', e);
  619. }
  620. }
  621. connectWebSocket();
  622. // Wire up printer selector
  623. var sel = document.getElementById('bb-printer-select');
  624. if (sel) {
  625. sel.addEventListener('change', function () {
  626. selectedPrinterId = parseInt(sel.value, 10) || null;
  627. });
  628. }
  629. // Load initial printer list
  630. apiFetch('/printers', {})
  631. .then(function (r) { return r.json(); })
  632. .then(function (list) {
  633. if (!Array.isArray(list)) return;
  634. list.forEach(function (p) {
  635. // Find existing entry (WS may have pushed one before API returned)
  636. var existing = null;
  637. for (var i = 0; i < printers.length; i++) {
  638. if (printers[i].id === p.id) { existing = printers[i]; break; }
  639. }
  640. if (existing) {
  641. // Fill in name/model that WS status messages don't carry
  642. if (p.name) existing.name = p.name;
  643. if (p.model) existing.model = p.model;
  644. } else {
  645. printers.push({ id: p.id, name: p.name, model: p.model, state: 'IDLE', progress: 0 });
  646. }
  647. });
  648. updatePrinterSelector();
  649. // Try to get bed size from first printer model
  650. if (list.length > 0 && list[0].model) {
  651. var m = list[0].model.toUpperCase();
  652. for (var modelName in BAMBU_BED_SIZES) {
  653. if (m.indexOf(modelName.toUpperCase()) !== -1) {
  654. currentBed = BAMBU_BED_SIZES[modelName];
  655. fakePrinterProfiles.currentProfileData(makeFakeProfileData(currentBed));
  656. break;
  657. }
  658. }
  659. }
  660. if (list.length > 0) selectedPrinterId = list[0].id;
  661. })
  662. .catch(function () {});
  663. console.log('[PrettyGCode] Bambuddy adapter initialised');
  664. // Wire up playback controls
  665. var playBtn = document.getElementById('bb-play-btn');
  666. var speedSel = document.getElementById('bb-play-speed');
  667. if (playBtn) {
  668. playBtn.addEventListener('click', function () {
  669. if (isPlaying) stopPlayback();
  670. else startPlayback();
  671. });
  672. }
  673. if (speedSel) {
  674. speedSel.addEventListener('change', function () {
  675. layersPerTick = parseInt(speedSel.value, 10) || 1;
  676. // Restart if already playing so speed takes effect immediately
  677. if (isPlaying) { stopPlayback(); startPlayback(); }
  678. });
  679. }
  680. }
  681. // -------------------------------------------------------------------------
  682. // 14. Playback engine
  683. // -------------------------------------------------------------------------
  684. var isPlaying = false;
  685. var playInterval = null;
  686. var layersPerTick = 1; // layers advanced per 50 ms tick
  687. var TICK_MS = 50; // ~20 fps
  688. function getSlider() { return $('#myslider-vertical'); }
  689. function startPlayback() {
  690. var $sl = getSlider();
  691. if (!$sl.length) return;
  692. var data = $sl.data('_pgslider');
  693. if (!data) return;
  694. var max = data.opts.max || 0;
  695. if (max === 0) return;
  696. // Restart from beginning if already at the end
  697. var cur = data.opts.value || 0;
  698. if (cur >= max) cur = 0;
  699. // Suppress live-print sync while playing
  700. var evStart = $.Event('slideStart'); evStart.value = cur; $sl.trigger(evStart);
  701. _setSliderLayer($sl, cur);
  702. isPlaying = true;
  703. _updatePlayBtn();
  704. playInterval = setInterval(function () {
  705. var d = getSlider().data('_pgslider');
  706. if (!d) { stopPlayback(); return; }
  707. var next = (d.opts.value || 0) + layersPerTick;
  708. if (next >= d.opts.max) {
  709. next = d.opts.max;
  710. _setSliderLayer(getSlider(), next);
  711. stopPlayback(/* skipEvStop */ false);
  712. return;
  713. }
  714. _setSliderLayer(getSlider(), next);
  715. }, TICK_MS);
  716. }
  717. function stopPlayback(skipEvStop) {
  718. if (playInterval) { clearInterval(playInterval); playInterval = null; }
  719. isPlaying = false;
  720. _updatePlayBtn();
  721. if (!skipEvStop) {
  722. var $sl = getSlider();
  723. if ($sl.length) {
  724. var d = $sl.data('_pgslider');
  725. var evStop = $.Event('slideStop');
  726. evStop.value = d ? d.opts.value : 0;
  727. $sl.trigger(evStop);
  728. }
  729. }
  730. }
  731. function _setSliderLayer($sl, layer) {
  732. $sl.slider('setValue', layer);
  733. var ev = $.Event('slide'); ev.value = layer; $sl.trigger(ev);
  734. $sl.find('.slider-handle').text(layer);
  735. }
  736. function _updatePlayBtn() {
  737. var btn = document.getElementById('bb-play-btn');
  738. if (btn) btn.textContent = isPlaying ? '⏸' : '▶';
  739. }
  740. // Run after all scripts have loaded.
  741. // buildFilePicker() runs immediately at DOM-ready — independent of viewmodel
  742. // init so the file picker is always functional even if prettygcode fails.
  743. // init() (viewmodel + 3D canvas) runs 200 ms later to let prettygcode.js
  744. // finish its own synchronous setup first.
  745. function onDomReady() {
  746. // Wire file-picker button — MUST be here (not an inline <script>) because
  747. // the CSP on this page allows script-src 'self' but NOT 'unsafe-inline',
  748. // so inline <script> blocks are blocked by the browser.
  749. var fileBtn = document.getElementById('bb-file-btn');
  750. var picker = document.getElementById('bb-file-picker');
  751. if (fileBtn && picker) {
  752. fileBtn.addEventListener('click', function (e) {
  753. picker.classList.toggle('bb-open');
  754. e.stopPropagation();
  755. });
  756. // Clicking outside the picker closes it
  757. document.addEventListener('click', function () {
  758. picker.classList.remove('bb-open');
  759. });
  760. // Clicks inside the picker don't close it
  761. picker.addEventListener('click', function (e) {
  762. e.stopPropagation();
  763. });
  764. }
  765. buildFilePicker();
  766. setTimeout(init, 200);
  767. }
  768. if (document.readyState === 'loading') {
  769. document.addEventListener('DOMContentLoaded', onDomReady);
  770. } else {
  771. onDomReady();
  772. }
  773. // -------------------------------------------------------------------------
  774. // Public API
  775. // -------------------------------------------------------------------------
  776. window.BambuddyPrettyGCode = {
  777. loadFile: loadFileById,
  778. getViewModel: function () { return viewModel; },
  779. play: startPlayback,
  780. stop: stopPlayback,
  781. };
  782. })();