iobroker.pjlink
Version:
PJLink Projector controll
761 lines (691 loc) • 33.3 kB
JavaScript
/**
*
* iobroker pjlink Adapter
*
* Copyright (c) 2022-2025, Bannsaenger <bannsaenger@gmx.de>
*
* MIT License
*
*/
const utils = require('@iobroker/adapter-core');
const pjlink = require('./lib/pjlink.js');
// const pjlink2 = require('./lib/pjlinkv2.js');
// possible query types
const queries = ['POWR', 'INPT', 'CLSS', 'AVMT', 'ERST', 'LAMP', 'INST', 'NAME', 'INF1', 'INF2', 'INFO'];
/**
* Projector status constants
* Four possible power states:
* 0 / pjlink.POWER.OFF
* 1 / pjlink.POWER.ON
* 2 / pjlink.POWER.COOLING_DOWN
* 3 / pjlink.POWER.WARMING_UP
*/
class Pjlink extends utils.Adapter {
/**
* @param {Partial<utils.AdapterOptions>} [options] Options from js-controller
*/
constructor(options) {
super({
...options,
name: 'pjlink',
});
// register callback functions
this.on('ready', this.onReady.bind(this));
this.on('stateChange', this.onStateChange.bind(this));
this.on('message', this.onMessage.bind(this));
this.on('unload', this.onUnload.bind(this));
// prepare global instance variables
this.projector = {};
this.connectedState = false; // true if connection to projector is established, will be reset on connection errors
this.poweredOn = false; // true if the power state is 1 (Power ON), used for status queries for which power must be ON
this.firstRunDone = false; // true if the first run (query status on adapter startup) is done
this.firstRunPowered = false; // true if the first run (query status on adapter startup with power = ON) is done
this.skippedShortCycles = -1; // number of skipped short cycles after power ON event. Will be set to the config value and decremented. -1 is expired
this.statusQueryInfo = {};
this.statusQueryInfo.startupPowered = [];
this.statusQueryInfo.startup = [];
this.statusQueryInfo.long = [];
this.statusQueryInfo.longPowered = [];
this.statusQueryInfo.short = [];
this.statusQueryInfo.shortPowered = [];
this.numStatusQryForInfo = 1; // after this number of status query a information query will be processed
this.actStatusQryForInfo = 0; // actual number of skipped status queries
this.unavailableTime = false; // true if the projector send the error "unavailable time" for the first time
this.timers = {}; // a place to store timers
this.timers.reconnectDelay = undefined;
this.timers.statusDelay = undefined;
}
/**
* Is called when databases are connected and adapter received configuration.
*/
async onReady() {
try {
//const self = this;
// Reset the connection indicator during startup
this.setState('info.connection', false, true);
this.numStatusQryForInfo = Math.floor(this.config.informationDelay / this.config.statusDelay);
await this.buildStatusQueryInfo();
this.conOptions = {
host: this.config.host || '127.0.0.1',
port: this.config.port || 4352,
password: this.config.password || null,
timeout: this.config.socketTimeout || 800,
logger: this.log,
};
// In order to get state updates, you need to subscribe to them. The following line adds a subscription for our variable we have created above.
this.subscribeStates('power');
this.subscribeStates('input');
this.subscribeStates('setMute');
this.log.info(
`PJLink connecting to host: ${this.conOptions.host}:${this.conOptions.port} (timeout: ${this.conOptions.timeout} ms), ${this.conOptions.password ? 'with password set' : 'with security disabled'}`,
);
this.log.warn(`password: ${this.conOptions.password}`);
// instantiate connection object for the projector
this.projector = new pjlink(this.conOptions);
// this.projector2 = new pjlink2(this.conOptions);
// try to communicate to the projector
this.reconnectProjector();
} catch (err) {
this.errorHandler(err, 'onReady');
}
}
/**
* Called to build the status query array
*/
async buildStatusQueryInfo() {
try {
for (const query of queries) {
const queryType = `queryType${query}`;
const queryTypePwr = `queryOnlyPwr${query}`;
switch (this.config[queryType]) {
case 1: // startup
if (this.config[queryTypePwr]) {
this.statusQueryInfo.startupPowered.push(query);
} else {
this.statusQueryInfo.startup.push(query);
}
break;
case 2: // short
if (this.config[queryTypePwr]) {
this.statusQueryInfo.shortPowered.push(query);
} else {
this.statusQueryInfo.short.push(query);
}
break;
case 3: // long
if (this.config[queryTypePwr]) {
this.statusQueryInfo.longPowered.push(query);
} else {
this.statusQueryInfo.long.push(query);
}
break;
default: // never and other
break;
}
}
this.log.debug(`Ready building statusQueryInfo: ${JSON.stringify(this.statusQueryInfo)}`);
} catch (err) {
this.errorHandler(err, 'buildStatusQueryInfo');
}
}
/**
* Called to reconnect to the projector
*/
reconnectProjector() {
try {
this.log.info(`PJLink trying to (re)connect to projector`);
// only the getPowerState for now
this.projector.getPowerState(this.pjlinkAnswerHandler.bind(this, 'GETPOWERSTATE'));
// and set the reconnect delay in advance, but only if not running
if (!this.timers.reconnectDelay) {
this.timers.reconnectDelay = setInterval(
this.reconnectProjector.bind(this),
this.config.reconnectDelay,
);
}
} catch (err) {
this.errorHandler(err, 'reconnectProjector');
}
}
/**
* check which status query has to be done
*
* @param {string} interval 'startup', 'short' or 'long'
*/
doStatusQuery(interval) {
try {
this.log.debug(`PJLink requesting projector information for interval: '${interval}'`);
if (interval === 'startup') {
// only called on projector connected
if (!this.firstRunDone) {
this.firstRunDone = true;
this.doQuery(this.statusQueryInfo.startup);
}
}
if (interval === 'short') {
// at first skip the cycles after power ON
if (this.skippedShortCycles > 0) {
this.skippedShortCycles--;
this.log.debug(`PJLink skipping 'short' cycle no: ${this.skippedShortCycles}`);
return;
}
this.skippedShortCycles = -1; // set to expired
// try to do the startupPowered queries
if (!this.firstRunPowered && this.poweredOn) {
this.firstRunPowered = true;
this.doQuery(this.statusQueryInfo.startupPowered);
}
this.doQuery(this.statusQueryInfo.short);
if (this.poweredOn) {
this.doQuery(this.statusQueryInfo.shortPowered);
}
}
if (interval === 'long') {
this.doQuery(this.statusQueryInfo.long);
if (this.poweredOn) {
this.doQuery(this.statusQueryInfo.longPowered);
}
}
} catch (err) {
this.errorHandler(err, 'doStatusQuery');
}
}
/**
* check which status query has to be done
*
* @param {object} queriesTodo a array with the queries which has to be done
*/
doQuery(queriesTodo) {
try {
for (const code of queriesTodo) {
// ['POWR', 'INPT', 'CLSS', 'AVMT', 'ERST', 'LAMP', 'INST', 'NAME', 'INF1', 'INF2', 'INFO']
switch (code) {
case 'POWR':
this.projector.getPowerState(this.pjlinkAnswerHandler.bind(this, 'GETPOWERSTATE'));
break;
case 'INPT':
this.projector.getInput(this.pjlinkAnswerHandler.bind(this, 'GETINPUT'));
break;
case 'CLSS':
this.projector.getClass(this.pjlinkAnswerHandler.bind(this, 'GETCLASS'));
break;
case 'AVMT':
this.projector.getMute(this.pjlinkAnswerHandler.bind(this, 'GETMUTE'));
break;
case 'ERST':
this.projector.getErrors(this.pjlinkAnswerHandler.bind(this, 'GETERRORS'));
break;
case 'LAMP':
this.projector.getLamps(this.pjlinkAnswerHandler.bind(this, 'GETLAMPS'));
break;
case 'INST':
this.projector.getInputs(this.pjlinkAnswerHandler.bind(this, 'GETINPUTS'));
break;
case 'NAME':
this.projector.getName(this.pjlinkAnswerHandler.bind(this, 'GETNAME'));
break;
case 'INF1':
this.projector.getManufacturer(this.pjlinkAnswerHandler.bind(this, 'GETMANUFACTURER'));
break;
case 'INF2':
this.projector.getModel(this.pjlinkAnswerHandler.bind(this, 'GETMODEL'));
break;
case 'INFO':
this.projector.getInfo(this.pjlinkAnswerHandler.bind(this, 'GETINFO'));
break;
default:
this.errorHandler(`Unknown query code '${code}'`, 'doStatusQuery');
}
}
} catch (err) {
this.errorHandler(err, 'doStatusQuery');
}
}
/**
* Called to refresh the projector status, main timer routine
*/
getProjectorStatus() {
try {
this.doStatusQuery('short');
// check wheter to do the long interval
this.actStatusQryForInfo++;
if (this.actStatusQryForInfo >= this.numStatusQryForInfo) {
this.actStatusQryForInfo = 0;
this.doStatusQuery('long');
}
} catch (err) {
this.errorHandler(err, 'getProjectorStatus');
}
}
/**
* Called to turn the projector on or off depemding on its actual state
*/
async projectorOnOff() {
try {
this.log.info(`PJLink power button pressed`);
// first get the projector status
const state = (await this.getStateAsync('powerStatus')) || { val: 0 };
const powerStatus = state.val || 0;
// reset power button status. Set as confirmed by hardware (ack = true)
this.setState('power', false, true);
if (powerStatus === 0) {
this.log.info(`PJLink Projector is currently off. Trying to switch projector on`);
this.projector.powerOn();
this.skippedShortCycles = this.config.skippedCyclesAfterPowerOn;
this.log.debug(`PJLink now skipping ${this.skippedShortCycles} times the 'short' query cycle`);
return;
}
if (powerStatus === 1) {
this.log.info(`PJLink Projector is currently on. Trying to switch projector off`);
this.projector.powerOff();
return;
}
// return if cooling or warming up
if (powerStatus === 2) {
this.log.info(`PJLink Projector is currently cooling down. Refuse to switch power`);
return;
}
if (powerStatus === 3) {
this.log.info(`PJLink Projector is currently warming up. Refuse to switch power`);
return;
}
} catch (err) {
this.errorHandler(err, 'projectorOnOff');
}
}
/**
* Called to set the mute status
*
* @param {number} status the mute status to set
*/
async setMute(status) {
try {
this.log.info(`PJLink mute status changed to: ${status}`);
this.projector.setMute(status, this.pjlinkAnswerHandler.bind(this, 'ERROR'));
} catch (err) {
this.errorHandler(err, 'setMute');
}
}
/**
* Called as answer function from pjlink functions
*
* @param {string} command called commands from PJLink to separate the value handling
* @param {any} pjlinkValues normaly the err and the state from the PJLink function call
*/
async pjlinkAnswerHandler(command, ...pjlinkValues) {
try {
// first look at the error state
const error = pjlinkValues[0];
let state;
if (error) {
switch (error.message) {
case 'Unavailable time':
if (!this.unavailableTime) {
this.unavailableTime = true;
this.log.warn(
`pjlinkAnswerHandler (command: ${command}), Projector is actualy unavailable. This is only logged once`,
);
}
break;
case 'Undefined command':
case 'Out of parameter':
case 'Authorization failed':
case 'Command reply mismatch':
case 'Not connected':
this.unavailableTime = false;
this.log.error(
`pjlinkAnswerHandler (command: ${command}), Projector send error: ${error.message}`,
);
break;
case 'Projector/Display failure':
default:
this.unavailableTime = true;
this.errorHandler(error, `pjlinkAnswerHandler (command: ${command})`);
// reset connection state
this.setState('info.connection', false, true);
// stop/restart timers
clearInterval(this.timers.statusDelay);
if (!this.timers.reconnectDelay) {
// Start reconnection only once
this.timers.reconnectDelay = setInterval(
this.reconnectProjector.bind(this),
this.config.reconnectDelay,
);
}
}
return;
}
if (pjlinkValues.length > 1) {
state = pjlinkValues[1];
this.log.debug(`PJLink got answer from command: '${command}', value '${JSON.stringify(state)}'`);
// reset unavailable time
this.unavailableTime = false;
// only if the reconnect timer is not cleared, this means, that the connection has been freshley established
if (this.timers.reconnectDelay) {
this.log.info(`PJLink established connection to the projector`);
// get the status info on startup
this.doStatusQuery('startup');
// clear the reconnect mechanism
this.clearInterval(this.timers.reconnectDelay);
this.timers.reconnectDelay = undefined;
// start timer for status and information update
this.timers.statusDelay = setInterval(this.getProjectorStatus.bind(this), this.config.statusDelay);
// set connection state
this.setState('info.connection', true, true);
}
// now parse the return values
let fan = 0;
let lamp = 0;
let temperature = 0;
let cover = 0;
let filter = 0;
let other = 0;
switch (command) {
case 'ERROR':
// only for error handling in callback.
break;
case 'GETPOWERSTATE':
this.setState('powerStatus', parseInt(state), true);
if (state == '1') {
this.poweredOn = true;
if (this.skippedShortCycles != -1) {
this.skippedShortCycles = this.config.skippedCyclesAfterPowerOn;
}
} else {
this.poweredOn = false;
this.skippedShortCycles = 0; // reset expired
}
break;
case 'GETINPUT':
this.setState('input', parseInt(state.code), true);
break;
case 'GETMUTE':
this.setState('videoMuteStatus', state.video, true);
this.setState('audioMuteStatus', state.audio, true);
this.setState('setMute', state.status, true); // new extended mute status
break;
case 'GETERRORS':
if (state) {
fan = state.fan === 'warning' ? 1 : state.fan === 'error' ? 3 : 0;
lamp = state.lamp === 'warning' ? 1 : state.lamp === 'error' ? 3 : 0;
temperature = state.temperature === 'warning' ? 1 : state.temperature === 'error' ? 3 : 0;
cover = state.cover === 'warning' ? 1 : state.cover === 'error' ? 3 : 0;
filter = state.filter === 'warning' ? 1 : state.filter === 'error' ? 3 : 0;
other = state.other === 'warning' ? 1 : state.other === 'error' ? 3 : 0;
}
this.setState('deviceInfo.fanErrorStatus', fan, true);
this.setState('deviceInfo.lampErrorStatus', lamp, true);
this.setState('deviceInfo.temperatureErrorStatus', temperature, true);
this.setState('deviceInfo.coverOpenStatus', cover, true);
this.setState('deviceInfo.filterErrorStatus', filter, true);
this.setState('deviceInfo.otherErrorStatus', other, true);
break;
case 'GETLAMPS':
this.setState(
'deviceInfo.lamps.lamp1Status',
parseInt(state[0].on === false ? '0' : '1'),
true,
);
this.setState('deviceInfo.lamps.lamp1Hours', parseInt(state[0].hours), true);
for (let lamps = 1; lamps < state.length; lamps++) {
const index = lamps + 1;
await this.setObjectNotExistsAsync(`deviceInfo.lamps.lamp${index}Status`, {
type: 'state',
common: {
role: 'indicator.maintenance',
name: {
en: `Status of lamp ${index}`,
de: `Status der Lampe ${index}`,
ru: `Статус лампы ${index}`,
pt: `Estado da lâmpada ${index}`,
nl: `Status van lamp ${index}`,
fr: `État du feu ${index}`,
it: `Stato della lampada ${index}`,
es: `Estado de la lámpara ${index}`,
pl: `Status lampy ${index}`,
uk: `Статус лампи ${index}`,
'zh-cn': `口粮${index}`,
},
type: 'number',
states: {
0: 'Off',
1: 'On',
},
read: true,
write: false,
def: 0,
},
native: {},
});
await this.setObjectNotExistsAsync(`deviceInfo.lamps.lamp${index}Hours`, {
_id: 'deviceInfo.lamps.lamp1Hours',
type: 'state',
common: {
role: 'value',
name: {
en: `Lighting time of lamp ${index}`,
de: `Leuchtdauer der Lampe ${index}`,
ru: `Время освещения лампы ${index}`,
pt: `Tempo de iluminação da lâmpada ${index}`,
nl: `Verlichtingstijd van lamp ${index}`,
fr: `Temps d'éclairage de la lampe ${index}`,
it: `Tempo di illuminazione della lampada ${index}`,
es: `Tiempo de iluminación de la lámpara ${index}`,
pl: `Czas świetlny lampy ${index}`,
uk: `Час освітлення лампи ${index}`,
'zh-cn': `A. 灯${index}`,
},
type: 'number',
min: 0,
max: 99999,
read: true,
write: false,
def: 0,
},
native: {},
});
this.setState(
`deviceInfo.lamps.lamp${index}Status`,
parseInt(state[lamps].on === false ? '0' : '1'),
true,
);
this.setState(`deviceInfo.lamps.lamp${index}Hours`, parseInt(state[lamps].hours), true);
}
break;
case 'GETINPUTS':
this.setState('deviceInfo.inputsAvailable', JSON.stringify(state), true);
break;
case 'GETNAME':
this.setState('deviceInfo.projectorName', JSON.stringify(state), true);
break;
case 'GETMANUFACTURER':
this.setState('deviceInfo.projectorManufacturer', JSON.stringify(state), true);
break;
case 'GETMODEL':
this.setState('deviceInfo.productName', JSON.stringify(state), true);
break;
case 'GETINFO':
this.setState('deviceInfo.otherInfo', JSON.stringify(state), true);
break;
case 'GETCLASS':
this.setState('deviceInfo.class', parseInt(state), true);
break;
default:
this.log.info(`PJLink unsupported command '${command}'`);
}
}
} catch (err) {
this.errorHandler(err, 'pjlinkAnswerHandler');
}
}
/**
* Called on error situations and from catch blocks
*
* @param {any} err the error occured
* @param {string} module module name in which the error occured
*/
errorHandler(err, module = '') {
this.log.error(`PJLink error in method: [${module}] error: ${err.message}, stack: ${err.stack}`);
}
/**
* Is called when adapter shuts down - callback has to be called under any circumstances!
*
* @param {() => void} callback the callback which must be called under any circumstances
*/
onUnload(callback) {
try {
// End the PJLink connection
this.projector.disconnect();
// Here you must clear all timeouts or intervals that may still be active
clearInterval(this.timers.statusDelay);
clearInterval(this.timers.reconnectDelay);
// Reset the connection indicator
this.setState('info.connection', false, true);
callback();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
callback();
}
}
/**
* Is called if a subscribed state changes
*
* @param {string} id the id of the state with a change
* @param {ioBroker.State | null | undefined} state the state object
*/
onStateChange(id, state) {
try {
if (state) {
if (state.ack) {
this.log.debug(`PJLink state ${id} changed: ${state.val} (ack = ${state.ack})`);
} else {
this.log.info(`PJLink state ${id} changed: ${state.val} (ack = ${state.ack})`);
}
// The state was changed
if (!state.ack) {
// only if the state is set manually
const onlyId = id.replace(`${this.namespace}.`, '');
switch (onlyId) {
case 'power':
this.projectorOnOff();
break;
case 'input':
// the string value is parsed by the pjlink.inputCommand.
// For the future and Class 2 it is the preferred format because of e.g. input 3B
// @ts-expect-error state.val can be null but isnt
this.projector.setInput(state.val.toString());
break;
case 'setMute':
// @ts-expect-error state.val is surely a int at this point
this.setMute(parseInt(state.val));
break;
}
}
}
} catch (err) {
this.errorHandler(err, 'onStateChange');
}
}
/**
* Some message was sent to this instance over message box. Used by email, pushover, text2speech, ...
* Using this method requires "common.messagebox" property to be set to true in io-package.json
*
* @param {ioBroker.Message} obj the message object
*/
async onMessage(obj) {
if (typeof obj === 'object') {
if (obj.command === 'updateInputs') {
this.log.debug(`updateInputs command gets: ${JSON.stringify(obj)}`);
const state = (await this.getStateAsync('deviceInfo.inputsAvailable')) || { val: '' };
const inputsAvailable = state.val || '';
this.log.info(
`Set the following inputs in the native object "system.adapter.${this.namespace}": ${JSON.stringify(inputsAvailable)}`,
);
const instanceObject = await this.getForeignObjectAsync(`system.adapter.${this.namespace}`);
try {
// @ts-expect-error expression works at this point
instanceObject.native.inputInfo = JSON.parse(inputsAvailable);
} catch (err) {
this.errorHandler(err, 'onMessage (parse inputsAvailable)');
}
// @ts-expect-error expression works at this point because instanceObject is retrieved before
this.setForeignObject(`system.adapter.${this.namespace}`, instanceObject);
if (obj.callback) {
this.sendTo(obj.from, obj.command, 'done', obj.callback);
}
}
if (obj.command === 'setInstanceInputs') {
if (obj.command === 'setInstanceInputs') {
this.log.debug(`setInstanceInputs command gets: ${JSON.stringify(obj)}`);
const inputObj = await this.getObjectAsync('input');
// @ts-expect-error expression works at this point
if (inputObj.common.min) {
// @ts-expect-error expression works at this point
delete inputObj.common.min;
}
// @ts-expect-error expression works at this point
if (inputObj.common.max) {
// @ts-expect-error expression works at this point
delete inputObj.common.max;
}
// @ts-expect-error expression works at this point
inputObj.common.states = {};
for (const i in this.config.inputInfo) {
// @ts-expect-error expression works at this point
const inputCode = this.config.inputInfo[i].code;
// @ts-expect-error expression works at this point
const inputName = this.config.inputInfo[i].name;
// @ts-expect-error expression works at this point
Object.assign(inputObj.common.states, { [inputCode]: inputName });
}
this.log.info(
// @ts-expect-error expression works at this point
`setInstanceInputs command sets inputs common to: ${JSON.stringify(inputObj.common)}`,
);
// @ts-expect-error expression works at this point
await this.setObjectAsync('input', inputObj);
if (obj.callback) {
this.sendTo(obj.from, obj.command, 'done', obj.callback);
}
}
}
if (obj.command === 'resetInstanceInputs') {
if (obj.command === 'resetInstanceInputs') {
this.log.debug(`resetInstanceInputs command gets: ${JSON.stringify(obj)}`);
const inputObj = await this.getObjectAsync('input');
// @ts-expect-error expression works at this point
if (inputObj.common.states) {
// @ts-expect-error expression works at this point
delete inputObj.common.states;
}
// @ts-expect-error expression works at this point
inputObj.common.min = 11;
// @ts-expect-error expression works at this point
inputObj.common.max = 59;
this.log.info(
// @ts-expect-error expression works at this point
`resetInstanceInputs command sets inputs common to: ${JSON.stringify(inputObj.common)}`,
);
// @ts-expect-error expression works at this point
this.setObject('input', inputObj);
if (obj.callback) {
this.sendTo(obj.from, obj.command, 'done', obj.callback);
}
}
}
}
}
}
if (require.main !== module) {
// Export the constructor in compact mode
/**
* @param {Partial<utils.AdapterOptions>} [options] Options from js-controller
*/
module.exports = options => {
'use strict';
new Pjlink(options);
};
} else {
// otherwise start the instance directly
new Pjlink();
}