2
0
mirror of https://github.com/ad1217/PrinterStatus synced 2024-11-24 08:23:48 -05:00

Move octoprint communication to the server side with websockets

This commit is contained in:
Adam Goldsmith 2019-09-20 15:46:05 -04:00
parent f163fde565
commit ba54ba3db4
9 changed files with 429 additions and 125 deletions

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
/.cache/
/dist/
/node_modules/
/src/*.js
/config.yaml

71
package-lock.json generated
View File

@ -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",

View File

@ -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"
}
}

View File

@ -1,30 +1,47 @@
<template>
<div>
<PrinterTile
v-for="printer in printers"
:key="printer.address"
v-bind="printer"
<PrinterCard
v-for="(status, name) in printers"
:key="name"
:name="name"
:status="status"
>
</PrinterTile>
</PrinterCard>
</div>
</template>
<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator';
import PrinterTile from './PrinterTile.vue';
import * as messages from './messages';
import * as octoprint from './octoprint';
import PrinterCard from './PrinterCard.vue';
@Component({ components: { PrinterTile } })
@Component({ components: { PrinterCard } })
export default class App extends Vue {
printers = [
{
address: 'http://octopi.local:5000/',
apikey: 'BEF073DD42A64431BDD72D83FA563DF5',
},
{
address: 'http://octopi.local:5000/',
apikey: 'BEF073DD42A64431BDD72D83FA563DF5',
},
];
websocket!: WebSocket;
printers: {
[key: string]: octoprint.CurrentOrHistoryPayload | null;
} = {};
mounted() {
let loc = window.location;
// TODO: make dynamic
// const ws_uri: string = loc.protocol === 'https' ? 'wss://' : 'ws://' + loc.host + '/ws';
const ws_uri = 'ws://localhost:4321';
this.websocket = new WebSocket(ws_uri);
this.websocket.onmessage = (ev: MessageEvent) => {
const event: messages.ExtendedMessage = JSON.parse(ev.data as string);
console.log(event);
if ('init' in event) {
this.$set(this.printers, event.printer, null);
} else if ('current' in event) {
this.$set(this.printers, event.printer, event.current);
} else if ('history' in event) {
this.$set(this.printers, event.printer, event.history);
}
};
}
}
</script>

60
src/PrinterCard.vue Normal file
View File

@ -0,0 +1,60 @@
<template>
<div class="card">
<div class="card-header">{{ name || 'Unknown' }}</div>
<div v-if="webcamURL">
<img class="webcam" :src="webcamURL" />
</div>
<div v-if="status">
<div>{{ status.state.text }}</div>
<div>Job File Name: {{ status.job.file.name || 'None' }}</div>
<div>
Job Completion:
<progress
v-if="status.progress.completion"
:value="status.progress.completion"
>
{{ status.progress.completion }}
</progress>
<span v-else> - </span>
</div>
<div>User: {{ status.job.user || '-' }}</div>
</div>
</div>
</template>
<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator';
import * as octoprint from './octoprint';
@Component
export default class PrinterCard extends Vue {
@Prop(String) readonly name!: string;
@Prop(String) readonly webcamURL!: string;
@Prop(Object) readonly status?: octoprint.CurrentOrHistoryPayload;
mounted() {}
}
</script>
<style lang="scss">
.card {
display: inline-block;
margin: 1em;
padding: 1em;
border-radius: 3px;
box-shadow: rgba(0, 0, 0, 0.2) 0px 3px 1px -2px,
rgba(0, 0, 0, 0.14) 0px 2px 2px 0px, rgba(0, 0, 0, 0.12) 0px 1px 5px 0px;
.card-header {
font-weight: bold;
font-size: 1.5em;
text-align: center;
padding-bottom: 0.25em;
}
}
.webcam {
max-width: 100%;
}
</style>

View File

@ -1,107 +0,0 @@
<template>
<div v-if="currentJob" class="card">
<div class="card-header">{{ name || 'Unknown' }}</div>
<div v-if="webcamURL">
<img class="webcam" :src="webcamURL" />
</div>
<div>Job File Name: {{ currentJob.job.file.name || 'None' }}</div>
<div>
Job Completion:
<progress
v-if="currentJob.progress.completion"
:value="currentJob.progress.completion"
>
{{ currentJob.progress.completion }}
</progress>
<span v-else> - </span>
</div>
<div>User: {{ currentJob.job.user || '-' }}</div>
</div>
</template>
<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator';
interface IJobInfo {
job: {
file: {
name: string;
display: string;
path: string;
type: string;
typePath: Array<string>;
};
user?: string;
estimatedPrintTime?: number;
lastPrintTime?: number;
filament?: {
length?: number;
volume?: number;
};
};
progress: {
completion: number;
filepos: number;
printTime: number;
printTimeLeft: number;
};
state: string;
}
@Component
export default class PrinterTile extends Vue {
@Prop(String) readonly address!: string;
@Prop(String) readonly apikey!: string;
name: string = '';
webcamURL: string | null = null;
currentJob: IJobInfo | null = null;
mounted() {
fetch(this.address + '/api/settings', {
headers: {
'X-Api-Key': this.apikey,
},
})
.then(r => r.json())
.then(r => {
this.webcamURL = r.webcam.streamUrl;
this.name = r.appearance.name;
})
.then(() =>
fetch(this.address + '/api/job', {
headers: {
'X-Api-Key': this.apikey,
},
})
)
.then(r => r.json())
.then(r => {
this.currentJob = r;
})
.catch(console.error);
}
}
</script>
<style lang="scss">
.card {
display: inline-block;
margin: 1em;
padding: 1em;
border-radius: 3px;
box-shadow: rgba(0, 0, 0, 0.2) 0px 3px 1px -2px,
rgba(0, 0, 0, 0.14) 0px 2px 2px 0px, rgba(0, 0, 0, 0.12) 0px 1px 5px 0px;
.card-header {
font-weight: bold;
font-size: 1.5em;
text-align: center;
padding-bottom: 0.25em;
}
}
.webcam {
max-width: 100%;
}
</style>

5
src/messages.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
import { Message } from './octoprint';
export type ExtendedMessage = (Message | { init?: null }) & {
printer: string;
};

113
src/octoprint.d.ts vendored Normal file
View File

@ -0,0 +1,113 @@
export interface JobInformation {
file: {
name: string;
display: string;
path: string;
type: string;
typePath: Array<string>;
};
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
>;

131
src/server.ts Normal file
View File

@ -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<any> {
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<any> {
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));
});