diff --git a/.gitignore b/.gitignore index ca36288..5ce7ce6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /.cache/ /dist/ /node_modules/ +/src/*.js +/config.yaml diff --git a/package-lock.json b/package-lock.json index f079ff5..117640c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -177,6 +177,32 @@ "to-fast-properties": "^2.0.0" } }, + "@types/js-yaml": { + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-3.12.1.tgz", + "integrity": "sha512-SGGAhXLHDx+PK4YLNcNGa6goPf9XRWQNAUUbffkwVGGXIxmDKWyGGL4inzq2sPmExu431Ekb9aEMn9BkPqEYFA==" + }, + "@types/node": { + "version": "12.7.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.7.4.tgz", + "integrity": "sha512-W0+n1Y+gK/8G2P/piTkBBN38Qc5Q1ZSO6B5H3QmPCUewaiXOo2GCAWZ4ElZCcNhjJuBSUSLGFUJnmlCn5+nxOQ==" + }, + "@types/node-fetch": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.0.tgz", + "integrity": "sha512-TLFRywthBgL68auWj+ziWu+vnmmcHCDFC/sqCOQf1xTz4hRq8cu79z8CtHU9lncExGBsB8fXA4TiLDLt6xvMzw==", + "requires": { + "@types/node": "*" + } + }, + "@types/ws": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-6.0.3.tgz", + "integrity": "sha512-yBTM0P05Tx9iXGq00BbJPo37ox68R5vaGTXivs6RGh/BQ6QP5zqZDGWdAO6JbRE/iR1l80xeGAwCQS2nMV9S/w==", + "requires": { + "@types/node": "*" + } + }, "@vue/component-compiler-utils": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@vue/component-compiler-utils/-/component-compiler-utils-3.0.0.tgz", @@ -248,6 +274,14 @@ "readable-stream": "^2.0.6" } }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "requires": { + "sprintf-js": "~1.0.2" + } + }, "asn1": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", @@ -261,6 +295,11 @@ "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" }, + "async-limiter": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", + "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==" + }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -478,6 +517,11 @@ "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", "dev": true }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" + }, "esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -735,6 +779,15 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true }, + "js-yaml": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", + "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, "jsbn": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", @@ -861,6 +914,11 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, + "node-fetch": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz", + "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==" + }, "node-gyp": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-5.0.3.tgz", @@ -1095,6 +1153,11 @@ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" + }, "sshpk": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", @@ -1317,6 +1380,14 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" }, + "ws": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.1.2.tgz", + "integrity": "sha512-gftXq3XI81cJCgkUiAVixA0raD9IVmXqsylCrjRygw4+UOOGzPoxnQ6r/CnVL9i+mDncJo94tSkyrtuuQVBmrg==", + "requires": { + "async-limiter": "^1.0.0" + } + }, "yallist": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", diff --git a/package.json b/package.json index 68f9d5a..aadf813 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,21 @@ "vue-template-compiler": "^2.6.10" }, "dependencies": { + "@types/js-yaml": "^3.12.1", + "@types/node": "^12.7.4", + "@types/node-fetch": "^2.5.0", + "@types/ws": "^6.0.3", + "js-yaml": "^3.13.1", + "node-fetch": "^2.6.0", "node-gyp": "^5.0.3", "vue": "^2.6.10", "vue-hot-reload-api": "^2.3.3", - "vue-property-decorator": "^8.2.2" + "vue-property-decorator": "^8.2.2", + "ws": "^7.1.2" + }, + "scripts": { + "build": "tsc src/server.ts", + "start": "parcel src/index.html", + "serve": "npm run build && node src/server.js" } } diff --git a/src/App.vue b/src/App.vue index 0520170..8c25a49 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,30 +1,47 @@ diff --git a/src/PrinterCard.vue b/src/PrinterCard.vue new file mode 100644 index 0000000..3f56dc4 --- /dev/null +++ b/src/PrinterCard.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/src/PrinterTile.vue b/src/PrinterTile.vue deleted file mode 100644 index 26b6c61..0000000 --- a/src/PrinterTile.vue +++ /dev/null @@ -1,107 +0,0 @@ - - - - - diff --git a/src/messages.d.ts b/src/messages.d.ts new file mode 100644 index 0000000..26983a4 --- /dev/null +++ b/src/messages.d.ts @@ -0,0 +1,5 @@ +import { Message } from './octoprint'; + +export type ExtendedMessage = (Message | { init?: null }) & { + printer: string; +}; diff --git a/src/octoprint.d.ts b/src/octoprint.d.ts new file mode 100644 index 0000000..f6e0763 --- /dev/null +++ b/src/octoprint.d.ts @@ -0,0 +1,113 @@ +export interface JobInformation { + file: { + name: string; + display: string; + path: string; + type: string; + typePath: Array; + }; + user?: string; + estimatedPrintTime?: number; + lastPrintTime?: number; + filament?: { + length?: number; + volume?: number; + }; +} + +export interface JobResponse { + job: JobInformation; + progress: ProgressInformation; + state: string; +} + +export interface ProgressInformation { + completion: number; + filepos: number; + printTime: number; + printTimeLeft: number; +} + +export interface PrinterState { + text: string; + flags: { + operational: boolean; + paused: boolean; + printing: boolean; + pausing: boolean; + cancelling: boolean; + sdReady: boolean; + error: boolean; + ready: boolean; + closedOrError: boolean; + }; +} + +export interface TemperatureOffsets { + // 'tool{n}' or 'bed' + [key: string]: number; +} + +export interface TemperatureData { + actual: number; + target: number; + offset?: number; +} + +export interface HistoricTemperatureDataPoint { + time: number; // unix timestamp + bed: TemperatureData; + // 'tool{n}' + [key: string]: TemperatureData | number; +} + +export interface LoginResponse { + name: string; + active: boolean; + admin: boolean; + user: boolean; + apikey: string; + settings: { [key: string]: string }; + session: string; + _is_external_client: boolean; +} + +export interface CurrentOrHistoryPayload { + state: PrinterState; + job: JobInformation; + progress: ProgressInformation; + currentZ: number; + offsets?: TemperatureOffsets; + temps: HistoricTemperatureDataPoint; +} + +export interface SlicingProgressMessage { + slicingProgress: { + slicer: string; + source_location: string; + source_path: string; + dest_location: string; + dest_path: string; + progress: number; + }; +} + +interface ConnectedMessage { + connected: { + apikey: string; + version: string; + branch: string; + display_version: string; + plugin_hash: string; + config_hash: string; + }; +} + +interface CurrentOrHistoryMessage { + current: CurrentOrHistoryPayload; + history: CurrentOrHistoryPayload; +} + +export type Message = Partial< + SlicingProgressMessage | ConnectedMessage | CurrentOrHistoryMessage +>; diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..43174ad --- /dev/null +++ b/src/server.ts @@ -0,0 +1,131 @@ +import * as fs from 'fs'; +import * as url from 'url'; + +import fetch from 'node-fetch'; +import * as messages from './messages'; +import * as octoprint from './octoprint'; +import * as WebSocket from 'ws'; +import * as yaml from 'js-yaml'; + +// Load config +const config: { + printers: { address: string; apikey: string }[]; +} = yaml.safeLoad(fs.readFileSync('config.yaml', 'utf8')); + +const proxyServer = new WebSocket.Server({ host: '127.0.0.1', port: 4321 }); +let printerStatuses: PrinterStatus[] = []; + +function broadcast(data: WebSocket.Data) { + proxyServer.clients.forEach((client: WebSocket) => { + if (client.readyState === WebSocket.OPEN) { + client.send(data); + } + }); +} + +function broadcastPayload(payload: messages.ExtendedMessage) { + broadcast(JSON.stringify(payload)); +} + +class PrinterStatus { + wss: WebSocket.Server; + address: string; + apikey: string; + + webcamURL?: string; + name?: string; + + websocket?: WebSocket; + lastStatus?: messages.ExtendedMessage; + + constructor(wss: WebSocket.Server, address: string, apikey: string) { + this.wss = wss; + this.address = address; + this.apikey = apikey; + + try { + this.init(); // async init + } catch (e) { + throw 'Failed to Init' + e; + } + } + + async init() { + // TODO: error handling (try/catch) + const settings = await this.api_get('settings'); + this.webcamURL = settings.webcam.streamUrl; + this.name = settings.appearance.name; + + // do passive login to get a session key from the API key + const login: octoprint.LoginResponse = await this.api_post('login', { + passive: 'true', + }); + + this.websocket = new WebSocket( + url.resolve(this.address, '/sockjs/websocket') + ); + this.websocket + .on('open', () => { + this.websocket!.send( + JSON.stringify({ auth: login.name + ':' + login.session }) + ); + }) + .on('message', (data: WebSocket.Data) => { + const event: octoprint.Message = JSON.parse(data as string); + console.log(event); + + let ext_event: messages.ExtendedMessage = { + ...event, + printer: this.name!, + }; + broadcastPayload(ext_event); + + if ('current' in event || 'history' in event) { + this.lastStatus = ext_event; + } + }); + } + + async api_get(endpoint: string): Promise { + const r = await fetch(url.resolve(this.address, '/api/' + endpoint), { + headers: { 'X-Api-Key': this.apikey }, + }); + return await r.json(); + } + + async api_post(endpoint: string, data: any): Promise { + const r = await fetch(url.resolve(this.address, '/api/' + endpoint), { + headers: { + 'X-Api-Key': this.apikey, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + method: 'POST', + body: JSON.stringify(data), + }); + return await r.json(); + } + + send_init(ws: WebSocket) { + let payload: messages.ExtendedMessage; + console.log(this.lastStatus); + if (this.lastStatus) { + payload = this.lastStatus; + } else { + payload = { init: null, printer: this.name! }; + } + ws.send(JSON.stringify(payload)); + } +} + +function init_printers() { + printerStatuses = config.printers.map( + printer => new PrinterStatus(proxyServer, printer.address, printer.apikey) + ); +} + +init_printers(); + +proxyServer.on('connection', (ws: WebSocket) => { + printerStatuses.forEach((ps: PrinterStatus) => ps.send_init(ws)); +});