-
Notifications
You must be signed in to change notification settings - Fork 6
/
main.js
752 lines (674 loc) · 22.5 KB
/
main.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
'use strict';
/*
* Created with @iobroker/create-adapter v1.31.0
*/
// The adapter-core module gives you access to the core ioBroker functions
// you need to create an adapter
const utils = require('@iobroker/adapter-core');
const {default: axios} = require('axios'); // Lib to handle http requests
const stateAttr = require('./lib/stateAttr.js'); // Load attribute library
const WebSocket = require('ws');
let ws = null; //Global variable reserved for socket connection
let reconnectTimer = null; // Polling timer
let connectionState = null;
const stateExpire = {}, warnMessages = {}; // Timers to reset online state of device
const disableSentry = false; // Ensure to set to true during development !
const https = require('node:https');
class KlipperMoonraker extends utils.Adapter {
/**
* @param {Partial<utils.AdapterOptions>} [options={}]
*/
constructor(options) {
super({
...options,
name: 'klipper-moonraker',
});
this.on('ready', this.onReady.bind(this));
this.on('stateChange', this.onStateChange.bind(this));
this.on('unload', this.onUnload.bind(this));
/** Refresh token after X ms */
this.REFRESH_TOKEN_MS = 50 * 60 * 1_000;
/** Retry if login failed after X ms */
this.RETRY_LOGIN_MS = 90_000;
/** The one shot token for websocket auth */
this.oneShotToken = '';
/** The current token used for authentication */
this.token = '';
/** The current refresh token */
this.refreshToken = '';
/** Array to store state objects to avoid unneeded object changes */
this.createdStatesDetails = {};
/** Store all available methods to handle data calls */
this.availableMethods = {};
/** List of config definitions for subscription of events */
this.subscribeMethods = {};
this.axios = axios.create();
}
/**
* Get the one shot token for authenticating websocket connection
*
* @return {Promise<void>}
*/
async getOneShotToken() {
try {
const res = await this.axios.get(`${this.getApiBaseUrl()}/access/oneshot_token`, {
headers: {
Authorization: `Bearer ${this.token}`
}
});
this.oneShotToken = res.data.result;
} catch (e) {
throw new Error(`Could not retrieve one shot token: ${e.message}`);
}
}
/**
* Perform login with credentials from config
*
* @return {Promise<void>}
*/
async login() {
this.log.info('Login into API');
try {
const res = await this.axios.post(`${this.getApiBaseUrl()}/access/login`,
{
'username': this.config.user,
'password': this.config.password,
'source': 'moonraker'
});
this.log.info(`Successfully logged in as ${res.data.result.username}`);
this.token = res.data.result.token;
this.refreshToken = res.data.result.refresh_token;
} catch (e) {
throw new Error(`Could not login: ${e.message}`);
}
}
/**
* Configure axios according to the instance config
*/
configureAxios() {
if (!this.config.useSsl) {
return;
}
this.axios = axios.create({
httpsAgent: new https.Agent({
rejectUnauthorized: false
})
});
}
/**
* Is called when databases are connected and adapter received configuration.
*/
async onReady() {
this.configureAxios();
return this.init();
}
/**
* Refresh the access token
*
* @return {Promise<void>}
*/
async refreshAccessToken() {
try {
const res = await this.axios.post(`${this.getApiBaseUrl()}/access/refresh_jwt`, { refresh_token: this.refreshToken });
this.token = res.data.result.token;
} catch (e) {
throw new Error(`Could not refresh access token: ${e.message}`);
}
}
/**
* Start the authorization procedure
* Login, getting one shot token and refreshing regularly
*
* @return {Promise<void>}
*/
async startAuthorization() {
await this.login();
await this.getOneShotToken();
this.setInterval(() => {
this.log.info('Refresh access token');
try {
this.refreshAccessToken();
this.log.info('Access token successfully refreshed');
} catch (e) {
this.log.error(`Could not refresh access token: ${e.message}`);
// we need to login from scratch, restart instance to achieve this
this.log.error('Restarting instance');
this.restart();
}
}, this.REFRESH_TOKEN_MS);
}
/**
* Executes the initial adapter logic
*
* @return {Promise<void>}
*/
async init() {
if (this.config.auth) {
try {
await this.startAuthorization();
} catch (e) {
this.log.error(e.message);
this.log.info(`Will try again in ${this.RETRY_LOGIN_MS / 1_000} seconds`);
this.setTimeout(() => this.init(), this.RETRY_LOGIN_MS);
return;
}
}
// Reset the connection indicator during startup
this.setState('info.connection', false, true);
return this.handleWebSocket();
}
/**
* Handle all websocket related data interaction
*/
async handleWebSocket() {
let wsUrl = `${this.config.useSsl ? 'wss': 'ws'}://${this.config.klipperIP}:${this.config.klipperPort}/websocket`;
if (this.config.auth) {
wsUrl += `?token=${this.oneShotToken}`;
}
// Open socket connection
ws = new WebSocket(wsUrl, {
rejectUnauthorized: !this.config.useSsl
});
// Connection successfully open, handle routine to initiates all objects and states
ws.on('open', () => {
console.log(`Connection Established`);
this.log.info(`Successfully connected to${this.config.klipperIP}:${this.config.klipperPort}`);
this.setState('info.connection', true, true);
connectionState = true;
// Get printer basic information
ws.send(JSON.stringify({
jsonrpc: '2.0',
method: 'printer.info',
id: 'printer.info'
}));
// Get active spool
ws.send(JSON.stringify({
'jsonrpc': '2.0',
'method': 'server.spoolman.get_spool_id',
'id': 'printer.spoolID'
}));
// Call update for all methods
this.getAvailableMethods();
});
// Handle messages received from socket connection
ws.on('message', async (data) => {
function errorOutput(data) {
console.warn(`Unexpected message received ${JSON.stringify(data)}`);
}
const rpc_data = JSON.parse(data);
// Handle error message and return function
if (rpc_data.error) {
console.error(`Received error message for "${rpc_data.id}" over websocket: ${rpc_data.error.message}`);
this.log.error(`Received error message for "${rpc_data.id}" over websocket: ${rpc_data.error.message}`);
return;
}
//Handle state_message Data
if (rpc_data.id) {
if (rpc_data.id == `printer.info`) {
// await this.readData(rpc_data.result, `_info`);
this.TraverseJson(rpc_data.result, null, false, false);
// Create additional states not included in JSON-API of klipper-mooonraker but available as SET command
await this.create_state('control.runGcode', 'Run G-code', '');
await this.create_state('control.emergencyStop', 'Emergency Stop', false);
await this.create_state('control.printCancel', 'Cancel current printing', false);
await this.create_state('control.printPause', 'Pause current printing', false);
await this.create_state('control.printResume', 'Resume current printing', false);
await this.create_state('control.restartFirmware', 'Restart Firmware', false);
await this.create_state('control.restartHost', 'Restart Host', false);
await this.create_state('control.restartServer', 'Restart Server', false);
await this.create_state('control.systemReboot', 'Reboot the system', false);
await this.create_state('control.systemShutdown', 'Shutdown the system', false);
} else if (rpc_data.id == `printer.objects.status`) {
this.TraverseJson(rpc_data.result.status, null, false, false);
} else if (rpc_data.id == `printer.objects.list`) {
// Ensure array is empty
this.availableMethods.objects = {};
// Create array with possible object/states subscriptions
for (const method in rpc_data.result.objects) {
this.availableMethods.objects[rpc_data.result.objects[method]] = null;
}
console.log(`All available methods : ${JSON.stringify(this.availableMethods.objects)}`);
// Request state data for all available methods
ws.send(JSON.stringify({
jsonrpc: '2.0',
method: 'printer.objects.query',
params: {
objects: this.availableMethods.objects
},
id: 'printer.objects.status',
}));
// Request status updates of all methods
this.subscribeMethods = this.availableMethods;
// Subscribe to all states including proper configuration
ws.send(JSON.stringify({
jsonrpc: '2.0',
method: 'printer.objects.subscribe',
params: {
objects: this.subscribeMethods.objects
},
id: 'printer.objects.subscribe'
}));
} else if (rpc_data.id === `printer.spoolID`) {
this.log.info(`PrinterSpool ID message: ${JSON.stringify(rpc_data)}`);
// await this.create_state('spoolID', 'Shutdown the system', false);
} else {
errorOutput(rpc_data);
}
} else if (rpc_data.method && rpc_data.method == 'notify_status_update' && rpc_data.params) {
console.log(`Status update data received ${JSON.stringify(rpc_data)}`);
for (const methods in rpc_data.params) {
this.log.debug(`Status update data received ${JSON.stringify(rpc_data)}`);
this.TraverseJson(rpc_data.params[methods], null, false, false);
}
} else {
errorOutput(rpc_data);
}
});
// Handle closure of socket connection, try to connect again in 10seconds (if adapter enabled)
ws.on('close', () => {
console.log(`Connection closed`);
this.log.info(`Connection closed`);
this.setState('info.connection', false, true);
connectionState = false;
// Try to reconnect if connections is closed after 10 seconds
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
reconnectTimer = setTimeout(() => {
console.log(`Trying to reconnect`);
this.log.info(`Trying to reconnect`);
if (this.config.auth) {
try {
this.getOneShotToken();
} catch (e) {
this.log.error(e.message);
}
}
this.handleWebSocket();
}, (10_000));
});
// Handle errors on socket connection
ws.on('error', (error) => {
console.error(`Connection error : ${error}`);
this.log.error(`Connection error : ${error}`);
this.setState('info.connection', false, true);
connectionState = false;
});
}
/**
* Query all available method endpoints, socket will reply with data which initialises all available states and objects
*/
getAvailableMethods() {
if (connectionState) {
// Printer Object list
ws.send(JSON.stringify({
jsonrpc: '2.0',
method: 'printer.objects.list',
id: 'printer.objects.list'
}));
} else {
console.error(`No active connection, cannot run 'getAvailableMethods'`);
}
}
/**
* Get the API base url based on the configuration
*
* @return {string}
*/
getApiBaseUrl() {
return `${this.config.useSsl ? 'https': 'http'}://${this.config.klipperIP}:${this.config.klipperPort}`;
}
async postAPI(endpoint) {
this.log.debug(`Post API called for: ${endpoint}`);
const headers = {};
if (this.config.auth) {
headers.Authorization = `Bearer ${this.token}`;
}
try {
if (!endpoint) return;
endpoint = `${this.getApiBaseUrl()}${endpoint}`;
const result = this.axios.post(endpoint, null, {headers})
.then((response) => {
this.log.debug(`Sending command to Klippy API: ${endpoint}`);
return response.data;
})
.catch((error) => {
this.log.debug(`Sending command to Klippy API: ${endpoint} failed with error ${error}`);
return error;
});
return result;
} catch (e) {
this.log.error(`Issue in postAPI: ${e.message}`);
}
}
/**
* Traverses the json-object and provides all information for creating/updating states
* @param {object} o Json-object to be added as states
* @param {string | null} parent Defines the parent object in the state tree
* @param {boolean} replaceName Steers if name from child should be used as name for structure element (channel)
* @param {boolean} replaceID Steers if ID from child should be used as ID for structure element (channel)
*/
async TraverseJson(o, parent = null, replaceName = false, replaceID = false) {
let id = null;
let value = null;
let name = null;
try {
for (const i in o) {
name = i;
if (!!o[i] && typeof (o[i]) == 'object' && o[i] == '[object Object]') {
if (parent == null) {
id = i;
if (replaceName) {
if (o[i].name) name = o[i].name;
}
if (replaceID) {
if (o[i].id) id = o[i].id;
}
} else {
id = parent + '.' + i;
if (replaceName) {
if (o[i].name) name = o[i].name;
}
if (replaceID) {
if (o[i].id) id = parent + '.' + o[i].id;
}
}
// Avoid channel creation for empty arrays/objects
if (Object.keys(o[i]).length !== 0) {
await this.setObjectAsync(id, {
'type': 'channel',
'common': {
'name': name,
},
'native': {},
});
this.TraverseJson(o[i], id, replaceName, replaceID);
} else {
console.log('State ' + id + ' received with empty array, ignore channel creation');
this.log.debug('State ' + id + ' received with empty array, ignore channel creation');
}
} else {
value = o[i];
if (parent == null) {
id = i;
} else {
id = parent + '.' + i;
}
if (typeof (o[i]) == 'object') value = JSON.stringify(value);
console.log('create id ' + id + ' with value ' + value + ' and name ' + name);
this.log.debug('create id ' + id + ' with value ' + value + ' and name ' + name);
this.create_state(id, name, value);
}
}
} catch (error) {
this.log.error(`Error in function TraverseJson: ${error}`);
}
}
/**
* Is called when adapter shuts down - callback has to be called under any circumstances!
*/
onUnload(callback) {
try {
// Cancel reconnect timer if running
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
// Close socket connection
if (ws) {
ws.close();
}
callback();
} catch (e) {
this.log.error(e);
callback();
}
}
/**
* Is called if a subscribed state changes
* @param {string} id
* @param {ioBroker.State | null | undefined} state
*/
async onStateChange(id, state) {
//Only execute when ACK = false
if (!state || state.ack) {
return;
}
// Split state name in segments to be used later
const deviceId = id.split('.');
// If state us related to control commands, customize API post call
this.log.debug(`Control command received ${deviceId[3]}`);
let apiResult = null;
switch (deviceId[3]) {
case 'emergencyStop':
apiResult = await this.postAPI(`/printer/emergency_stop`);
break;
case 'printCancel':
apiResult = await this.postAPI(`/printer/print/cancel`);
break;
case 'printPause':
apiResult = await this.postAPI(`/printer/print/pause`);
break;
case 'printResume':
apiResult = await this.postAPI(`/printer/print/resume`);
break;
case 'restartFirmware':
apiResult = await this.postAPI(`/printer/firmware_restart`);
break;
case 'restartHost':
apiResult = await this.postAPI(`/printer/restart`);
break;
case 'restartServer':
apiResult = await this.postAPI(`/server/restart`);
break;
case 'systemReboot':
apiResult = await this.postAPI(`/machine/reboot`);
break;
case 'systemShutdown':
apiResult = await this.postAPI(`/machine/shutdown`);
break;
case 'runGcode':
apiResult = await this.postAPI(`/printer/gcode/script?script=${state.val}`);
break;
}
if (apiResult) {
if (apiResult.result === 'ok') {
this.log.info(`Command "${deviceId[3]}" send successfully`);
} else {
this.log.error(`Sending command "${deviceId[3]}" failed, error : ${JSON.stringify(apiResult.message)}`);
}
}
}
/**
* @param stateName {string} ID of the state
* @param name {string} Name of state (also used for stattAttrlib!)
* @param value {boolean | string | null} Value of the state
*/
async create_state(stateName, name, value) {
this.log.debug(`Create_state called for : ${stateName} with value : ${value}`);
/**
* Value rounding 1 digits
* @param {number} [value] - Number to round with . separator
* @param {object} [adapter] - intance "this" object
*/
function rondOneDigit(value, adapter) {
try {
let rounded = Number(value);
rounded = Math.round(rounded * 100) / 100;
adapter.log.debug(`roundCosts with ${value} rounded ${rounded}`);
if (!rounded) return value;
return rounded;
} catch (error) {
adapter.log.error(`[roundCosts ${value}`);
adapter.sendSentry(error);
}
}
/**
* Value rounding 2 digits
* @param {number} [value] - Number to round with , separator
* @param {object} [adapter] - instance "this" object
*/
function roundTwoDigits(value, adapter) {
let rounded;
try {
rounded = Number(value);
rounded = Math.round(rounded * 1000) / 1000;
adapter.log.debug(`roundDigits with ${value} rounded ${rounded}`);
if (!rounded) return value;
return rounded;
} catch (error) {
adapter.log.error(`[roundDigits ${value}`);
adapter.sendSentry(error);
rounded = value;
return rounded;
}
}
/**
* Value rounding 3 digits
* @param {number} [value] - Number to round with , separator
* @param {object} [adapter] - intance "this" object
*/
function roundThreeDigits(value, adapter) {
let rounded;
try {
rounded = Number(value);
rounded = Math.round(rounded * 1000) / 1000;
adapter.log.debug(`roundDigits with ${value} rounded ${rounded}`);
if (!rounded) return value;
return rounded;
} catch (error) {
adapter.log.error(`[roundDigits ${value}`);
adapter.sendSentry(error);
rounded = value;
return rounded;
}
}
try {
// Try to get details from state lib, if not use defaults. throw warning is states is not known in attribute list
const common = {};
if (!stateAttr[name]) {
const warnMessage = `State attribute definition missing for "${name}" (received value ${value} - ${typeof value})`;
if (warnMessages[name] !== warnMessage) {
warnMessages[name] = warnMessage;
// Send information to Sentry
this.sendSentry(warnMessage);
}
}
let createStateName = stateName;
// Todo: Disable stateAttr based channel creation
// const channel = stateAttr[name] !== undefined ? stateAttr[name].root || '' : '';
const channel = '';
if (channel !== '') {
await this.setObjectNotExistsAsync(channel, {
type: 'channel',
common: {
name: stateAttr[name] !== undefined ? stateAttr[name].rootName || '' : '',
},
native: {},
});
createStateName = `${channel}.${stateName}`;
}
common.name = stateAttr[name] !== undefined ? stateAttr[name].name || name : name;
common.type = stateAttr[name] !== undefined ? stateAttr[name].type || typeof (value) : typeof (value);
common.role = stateAttr[name] !== undefined ? stateAttr[name].role || 'state' : 'state';
common.read = true;
common.unit = stateAttr[name] !== undefined ? stateAttr[name].unit || '' : '';
common.write = stateAttr[name] !== undefined ? stateAttr[name].write || false : false;
if ((!this.createdStatesDetails[stateName])
|| (this.createdStatesDetails[stateName]
&& (
common.name !== this.createdStatesDetails[stateName].name
|| common.name !== this.createdStatesDetails[stateName].name
|| common.type !== this.createdStatesDetails[stateName].type
|| common.role !== this.createdStatesDetails[stateName].role
|| common.read !== this.createdStatesDetails[stateName].read
|| common.unit !== this.createdStatesDetails[stateName].unit
|| common.write !== this.createdStatesDetails[stateName].write
)
)) {
// console.log(`An attribute has changed : ${state}`);
await this.extendObjectAsync(createStateName, {
type: 'state',
common
});
} else {
// console.log(`Nothing changed do not update object`);
}
// Store current object definition to memory
this.createdStatesDetails[stateName] = common;
// Check if value should be rounded, active switch
const roundingOneDigit = stateAttr[name] !== undefined ? stateAttr[name].round_1 || false : false;
const roundingTwoDigits = stateAttr[name] !== undefined ? stateAttr[name].round_2 || false : false;
const roundingThreeDigits = stateAttr[name] !== undefined ? stateAttr[name].round_3 || false : false;
// Set value to state including expiration time
if (value !== null && value !== undefined) {
// Check if value should be rounded, if yes execute
if (typeof value == 'number' || typeof value == 'string') {
if (roundingOneDigit) {
value = rondOneDigit(value, this);
} else if (roundingTwoDigits) {
value = roundTwoDigits(value, this);
} else if (roundingThreeDigits) {
value = roundThreeDigits(value, this);
}
}
await this.setStateChangedAsync(createStateName, {
val: value,
ack: true,
});
}
// Timer to set online state to FALSE when not updated during 2 time-sync intervals
if (name === 'klippy connected') {
// Clear running timer
if (stateExpire[stateName]) {
clearTimeout(stateExpire[createStateName]);
stateExpire[stateName] = null;
}
// timer
stateExpire[stateName] = setTimeout(async () => {
// Set value to state including expiration time
await this.setState(createStateName, {
val: false,
ack: true,
});
this.log.debug(`Online state expired for ${stateName}`);
}, this.config.apiRefreshInterval * 2_000);
this.log.debug('Expire time set for state : ' + name + ' with time in seconds : ' + this.config.apiRefreshInterval * 2);
}
// Subscribe on state changes if writable
common.write && this.subscribeStates(createStateName);
} catch (error) {
this.log.error(`Create state error = ${error}`);
}
}
/**
* Send error's to sentry, only if sentry not disabled
* @param {string} msg ID of the state
*/
sendSentry(msg) {
if (!disableSentry) {
if (this.supportsFeature && this.supportsFeature('PLUGINS')) {
const sentryInstance = this.getPluginInstance('sentry');
if (sentryInstance) {
this.log.info(`[Error caught and sent to Sentry, thank you for collaborating!] error: ${msg}`);
sentryInstance.getSentryObject().captureException(msg);
}
}
} else {
this.log.error(`Sentry disabled, error caught : ${msg}`);
console.error(`Sentry disabled, error caught : ${msg}`);
}
}
}
// @ts-ignore parent is a valid property on module
if (module.parent) {
// Export the constructor in compact mode
/**
* @param {Partial<utils.AdapterOptions>} [options={}]
*/
module.exports = (options) => new KlipperMoonraker(options);
} else {
// otherwise start the instance directly
new KlipperMoonraker();
}