iobroker.klipper-moonraker
Version:
Adapter to interact with klipper by API
789 lines (720 loc) • 30 kB
JavaScript
'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 = {};
const 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 = {};
/** Timeout method if no pong received in time */
this.pingTimeout = null;
/** Check for ping every X ms */
this.PING_INTERVAL = 30_000;
this.axios = axios.create();
}
/**
* Get the one shot token for authenticating websocket connection
*
* @returns {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
*
* @returns {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
*
* @returns {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
*
* @returns {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
*
* @returns {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
*/
handleWebSocket() {
let wsUrl = `${this.config.useSsl ? 'wss' : 'ws'}://${this.config.klipperIP}:${this.config.klipperPort}/websocket`;
if (this.config.auth) {
wsUrl += `?token=${this.oneShotToken}`;
}
/** Check that we receive a ping request in time */
const heartbeat = () => {
this.clearTimeout(this.pingTimeout);
this.log.debug('Heartbeat received');
this.pingTimeout = setTimeout(() => {
this.log.error('No heartbeat received in time');
ws.terminate();
}, this.PING_INTERVAL);
};
// Open socket connection
ws = new WebSocket(wsUrl, {
rejectUnauthorized: !this.config.useSsl,
});
ws.on('ping', heartbeat);
// Connection successfully open, handle routine to initiates all objects and states
ws.on('open', () => {
this.log.info(`Successfully connected to ${this.config.klipperIP}:${this.config.klipperPort}`);
this.setState('info.connection', true, true);
connectionState = true;
heartbeat();
// 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 => {
const errorOutput = data => {
this.log.debug(`Unexpected message received ${JSON.stringify(data)}`);
};
const rpc_data = JSON.parse(data);
// Handle error message and return function
if (rpc_data.error) {
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;
}
this.log.debug(`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) {
this.log.debug(`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', () => {
this.clearTimeout(this.pingTimeout);
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) {
this.clearTimeout(reconnectTimer);
reconnectTimer = null;
}
reconnectTimer = this.setTimeout(async () => {
this.log.info(`Trying to reconnect`);
if (this.config.auth) {
try {
await this.getOneShotToken();
} catch (e) {
this.log.error(e.message);
}
}
this.handleWebSocket();
}, 10_000);
});
// Handle errors on socket connection
ws.on('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 {
this.log.error(`No active connection, cannot run 'getAvailableMethods'`);
}
}
/**
* Get the API base url based on the configuration
*
* @returns {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 {
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);
}
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!
*
* @param callback
*/
onUnload(callback) {
try {
// Cancel reconnect timer if running
if (reconnectTimer) {
this.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))
) {
await this.extendObjectAsync(createStateName, {
type: 'state',
common,
});
}
// 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]) {
this.clearTimeout(stateExpire[createStateName]);
stateExpire[stateName] = null;
}
// timer
stateExpire[stateName] = this.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}`);
}
}
}
if (require.main !== module) {
// 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();
}