iobroker.lgtv
Version:
ioBroker LG WebOS SmartTV Adapter
1,086 lines (1,026 loc) • 51 kB
JavaScript
'use strict';
const utils = require('@iobroker/adapter-core');
let adapter;
const LGTV = require('lgtv2');
const wol = require('wol');
const fs = require('node:fs');
const path = require('node:path');
const { probeTcpReachable } = require('./lib/probe');
let hostUrl;
let isConnect = false;
let lgtvobj, clientKey, volume, oldvolume;
let keyfile = 'lgtvkeyfile';
let renewTimeout = null;
let healthInterval = null;
let curApp = '';
// Picture settings — write goes through createAlert with the alert immediately
// closed (the TV applies the luna setting via the onclose handler). Read goes
// through ssap://settings/getSystemSettings, one subscription per key so a
// single unsupported property does not silently kill the whole subscription.
const PICTURE_KEYS = new Set([
'pictureMode',
'brightness',
'backlight',
'contrast',
'color',
'colorTemperature',
'energySaving',
'eyeComfortMode',
'justScan',
]);
const PICTURE_NUMERIC_KEYS = new Set(['brightness', 'backlight', 'contrast', 'color', 'colorTemperature']);
// eyeComfortMode is the only picture setting with just two values, so the
// public state surface is `boolean` (true/false) for ergonomic scripting —
// users no longer have to write `'on'`/`'off'` strings. The Luna API still
// expects the lowercase strings, so we map at the boundary on both sides:
// boolean ⇄ 'on'/'off' in setPictureSetting / coercePictureValue.
const PICTURE_BOOLEAN_KEYS = new Set(['eyeComfortMode']);
// Picture writes go through the generic "picture" category; justScan is the
// one exception — it accepts writes via the dedicated "aspectRatio" category.
// All reads go through "picture"; webOS authorisation blocks reads of
// pictureMode/colorTemperature/eyeComfortMode/justScan from there as well, so
// those four properties end up effectively write-only on the public API.
function setCategoryFor(key) {
return key === 'justScan' ? 'aspectRatio' : 'picture';
}
// Map the boolean public state to the Luna-expected on/off string. Tolerates
// users who still write 'on'/'off' from existing scripts.
function pictureBoolToLuna(raw) {
if (typeof raw === 'boolean') {
return raw ? 'on' : 'off';
}
if (typeof raw === 'string') {
const t = raw.trim().toLowerCase();
if (t === 'on' || t === 'true' || t === '1') {
return 'on';
}
if (t === 'off' || t === 'false' || t === '0') {
return 'off';
}
}
return null;
}
function coercePictureValue(key, raw) {
if (PICTURE_NUMERIC_KEYS.has(key)) {
const n = typeof raw === 'number' ? raw : Number(raw);
return Number.isFinite(n) ? n : null;
}
if (PICTURE_BOOLEAN_KEYS.has(key)) {
// TV reports `'on'` / `'off'` — surface as boolean for the state.
if (typeof raw === 'string') {
const t = raw.trim().toLowerCase();
if (t === 'on') {
return true;
}
if (t === 'off') {
return false;
}
}
if (typeof raw === 'boolean') {
return raw;
}
return null;
}
return typeof raw === 'string' ? raw : String(raw);
}
// Reconnect watchdog: the underlying lgtv2 library relies on the `websocket@1`
// package for retries. If that socket gets stuck during the connect handshake
// (TCP open, no upgrade response, no `connectFailed` event), the library never
// schedules another retry and the adapter stays "offline" until the user
// restarts it. The watchdog observes how long ago the library last emitted a
// `connecting` event and forces a fresh LGTV instance once the gap exceeds
// the threshold while we're still disconnected. To avoid noisy warnings while
// the TV is simply powered off, the watchdog gates the recreate behind a quick
// TCP probe of the WebSocket port — the recreate (and warning) only fire when
// the TV is actually reachable on the network.
let lastConnectingAt = 0;
let watchdogTimer = null;
let watchdogProbeInFlight = false;
const WATCHDOG_CHECK_MS = 30000;
const WATCHDOG_STUCK_MS = 60000;
const WATCHDOG_PROBE_PORT = 3001;
const WATCHDOG_PROBE_TIMEOUT_MS = 2000;
function startAdapter(options) {
options = options || {};
Object.assign(options, {
systemConfig: true,
name: 'lgtv',
stateChange: (id, state) => {
if (id && state && !state.ack) {
if (state.val === undefined || state.val === null) {
return;
}
id = id.substring(adapter.namespace.length + 1);
let vals, dx, dy;
if (~state.val.toString().indexOf(',')) {
vals = state.val.toString().split(',');
dx = parseInt(vals[0]);
dy = parseInt(vals[1]);
}
adapter.log.debug(`State change "${id}" - VALUE: ${state.val}`);
if (id.startsWith('states.picture.')) {
const key = id.substring('states.picture.'.length);
if (PICTURE_KEYS.has(key) && state.val !== null && state.val !== undefined) {
setPictureSetting(key, state.val);
}
return;
}
switch (id) {
case 'states.popup':
adapter.log.debug(`Sending popup message "${state.val}" to WebOS TV: ${adapter.config.ip}`);
sendCommand('ssap://system.notifications/createToast', { message: state.val }, (err, _val) => {
if (!err) {
adapter.setState('states.popup', state.val, true);
}
});
break;
case 'states.turnOff':
adapter.log.debug(`Sending turn OFF command to WebOS TV: ${adapter.config.ip}`);
if (adapter.config.power) {
sendCommand('button', { name: 'power' }, (err, _val) => {
if (!err) {
adapter.setState('states.turnOff', state.val, true);
}
});
} else {
adapter.getState(`${adapter.namespace}.states.on`, (err, tv_on) => {
if (err) {
adapter.log.debug(`Error getting "on" state ${err}`);
return;
}
if (!tv_on.val) {
adapter.log.debug('TV is already off');
adapter.setState('states.turnOff', state.val, true);
return;
}
sendCommand('ssap://system/turnOff', (err, val) => {
if (!err && val.returnValue === true) {
adapter.setState('states.turnOff', state.val, true);
adapter.setState('states.on', false, true);
}
});
});
}
break;
case 'states.power':
if (!state.val) {
adapter.log.debug(`Sending turn OFF command to WebOS TV: ${adapter.config.ip}`);
if (adapter.config.power) {
sendCommand('button', { name: 'power' }, (err, _val) => {
if (!err) {
adapter.setState('states.power', state.val, true);
}
});
} else {
adapter.getState(`${adapter.namespace}.states.on`, (err, tv_on) => {
if (err) {
adapter.log.debug(`Error getting "on" state ${err}`);
return;
}
if (!tv_on.val) {
adapter.log.debug('TV is already off');
adapter.setState('states.power', state.val, true);
return;
}
sendCommand('ssap://system/turnOff', (err, val) => {
if (!err && val.returnValue === true) {
adapter.setState('states.power', state.val, true);
adapter.setState('states.on', false, true);
}
});
});
}
} else {
adapter.getState(`${adapter.namespace}.states.mac`, (err, macState) => {
adapter.log.debug(`GetState mac: ${JSON.stringify(macState)}`);
if (macState) {
if (adapter.config.wolwithip) {
adapter.log.debug(`Should include mac address with WOL packet.`);
wol.wake(macState.val, { address: adapter.config.ip }, (err, _res) => {
if (!err) {
adapter.log.debug(
`Send WOL to MAC: {${macState.val}} & IP: {${adapter.config.ip}} OK`,
);
}
});
} else {
wol.wake(macState.val, (err, _res) => {
if (!err) {
adapter.log.debug(`Send WOL to MAC: {${macState.val}} OK`);
}
});
}
} else {
adapter.log.error(
'Error get MAC address TV. Please turn on the TV manually first!',
);
}
});
}
break;
case 'states.mute':
adapter.log.debug(`Sending mute ${state.val} command to WebOS TV: ${adapter.config.ip}`);
sendCommand('ssap://audio/setMute', { mute: state.val }, (err, _val) => {
if (!err) {
adapter.setState('states.mute', state.val, true);
}
});
break;
case 'states.volume':
adapter.log.debug(
`Sending volume change ${state.val} command to WebOS TV: ${adapter.config.ip}`,
);
oldvolume = volume;
SetVolume(state.val);
break;
case 'states.volumeUp':
adapter.log.debug(`Sending volumeUp ${state.val} command to WebOS TV: ${adapter.config.ip}`);
sendCommand('ssap://audio/volumeUp', null, (err, _val) => {
if (!err) {
adapter.setState('states.volumeUp', !!state.val, true);
}
});
break;
case 'states.volumeDown':
adapter.log.debug(`Sending volumeDown ${state.val} command to WebOS TV: ${adapter.config.ip}`);
sendCommand('ssap://audio/volumeDown', null, (err, _val) => {
if (!err) {
adapter.setState('states.volumeDown', !!state.val, true);
}
});
break;
case 'states.channel':
adapter.log.debug(
`Sending switch to channel ${state.val} command to WebOS TV: ${adapter.config.ip}`,
);
sendCommand('ssap://tv/openChannel', { channelNumber: state.val.toString() }, (err, _val) => {
if (!err) {
adapter.setState('states.channel', state.val, true);
} else {
adapter.log.debug(`Error in switching to channel: ${err}`);
}
});
break;
case 'states.channelUp':
adapter.log.debug(`Sending channelUp ${state.val} command to WebOS TV: ${adapter.config.ip}`);
sendCommand('ssap://tv/channelUp', null, (err, _val) => {
if (!err) {
adapter.setState('states.channelUp', !!state.val, true);
}
});
break;
case 'states.channelDown':
adapter.log.debug(`Sending channelDown ${state.val} command to WebOS TV: ${adapter.config.ip}`);
sendCommand('ssap://tv/channelDown', null, (err, _val) => {
if (!err) {
adapter.setState('states.channelDown', !!state.val, true);
}
});
break;
case 'states.mediaPlay':
adapter.log.debug(`Sending mediaPlay ${state.val} command to WebOS TV: ${adapter.config.ip}`);
sendCommand('ssap://media.controls/play', null, (err, _) => {
if (!err) {
adapter.setState('states.mediaPlay', !!state.val, true);
}
});
break;
case 'states.mediaPause':
adapter.log.debug(`Sending mediaPause ${state.val} command to WebOS TV: ${adapter.config.ip}`);
sendCommand('ssap://media.controls/pause', null, (err, _val) => {
if (!err) {
adapter.setState('states.mediaPause', !!state.val, true);
}
});
break;
case 'states.openURL':
if (!state.val) {
return adapter.setState('states.openURL', '', true);
}
adapter.log.debug(`Sending open ${state.val} command to WebOS TV: ${adapter.config.ip}`);
sendCommand('ssap://system.launcher/open', { target: state.val }, (err, _val) => {
if (!err) {
adapter.setState('states.openURL', state.val, true);
}
});
break;
case 'states.mediaStop':
adapter.log.debug(`Sending mediaStop ${state.val} command to WebOS TV: ${adapter.config.ip}`);
sendCommand('ssap://media.controls/stop', null, (err, _val) => {
if (!err) {
adapter.setState('states.mediaStop', !!state.val, true);
}
});
break;
case 'states.mediaFastForward':
adapter.log.debug(
`Sending mediaFastForward ${state.val} command to WebOS TV: ${adapter.config.ip}`,
);
sendCommand('ssap://media.controls/fastForward', null, (err, _val) => {
if (!err) {
adapter.setState('states.mediaFastForward', !!state.val, true);
}
});
break;
case 'states.mediaRewind':
adapter.log.debug(`Sending mediaRewind ${state.val} command to WebOS TV: ${adapter.config.ip}`);
sendCommand('ssap://media.controls/rewind', null, (err, _val) => {
if (!err) {
adapter.setState('states.mediaRewind', !!state.val, true);
}
});
break;
case 'states.3Dmode':
adapter.log.debug(`Sending 3Dmode ${state.val} command to WebOS TV: ${adapter.config.ip}`);
switch (state.val) {
case true:
sendCommand('ssap://com.webos.service.tv.display/set3DOn', null, (err, _val) => {
if (!err) {
adapter.setState('states.3Dmode', !!state.val, true);
}
});
break;
case false:
sendCommand('ssap://com.webos.service.tv.display/set3DOff', null, (err, _val) => {
if (!err) {
adapter.setState('states.3Dmode', !!state.val, true);
}
});
break;
}
break;
case 'states.launch':
adapter.log.debug(`Sending launch command ${state.val} to WebOS TV: ${adapter.config.ip}`);
switch (state.val) {
case 'livetv':
adapter.log.debug(`Switching to LiveTV on WebOS TV: ${adapter.config.ip}`);
sendCommand(
'ssap://system.launcher/launch',
{ id: 'com.webos.app.livetv' },
(err, _val) => {
if (!err) {
adapter.setState('states.launch', state.val, true);
}
},
);
break;
case 'smartshare':
adapter.log.debug(`Switching to SmartShare App on WebOS TV: ${adapter.config.ip}`);
sendCommand(
'ssap://system.launcher/launch',
{ id: 'com.webos.app.smartshare' },
(err, _val) => {
if (!err) {
adapter.setState('states.launch', state.val, true);
}
},
);
break;
case 'tvuserguide':
adapter.log.debug(`Switching to TV Userguide App on WebOS TV: ${adapter.config.ip}`);
sendCommand(
'ssap://system.launcher/launch',
{ id: 'com.webos.app.tvuserguide' },
(err, _val) => {
if (!err) {
adapter.setState('states.launch', state.val, true);
}
},
);
break;
case 'netflix':
adapter.log.debug(`Switching to Netflix App on WebOS TV: ${adapter.config.ip}`);
sendCommand('ssap://system.launcher/launch', { id: 'netflix' }, (err, _val) => {
if (!err) {
adapter.setState('states.launch', state.val, true);
}
});
break;
case 'youtube':
adapter.log.debug(`Switching to Youtube App on WebOS TV: ${adapter.config.ip}`);
sendCommand(
'ssap://system.launcher/launch',
{ id: 'youtube.leanback.v4' },
(err, _val) => {
if (!err) {
adapter.setState('states.launch', state.val, true);
}
},
);
break;
case 'prime':
adapter.log.debug(`Switching to Amazon Prime App on WebOS TV: ${adapter.config.ip}`);
sendCommand('ssap://system.launcher/launch', { id: 'lovefilm.de' }, (err, _val) => {
if (!err) {
adapter.setState('states.launch', state.val, true);
}
});
break;
case 'amazon':
adapter.log.debug(`Switching to Amazon Prime App on WebOS TV: ${adapter.config.ip}`);
sendCommand('ssap://system.launcher/launch', { id: 'amazon' }, (err, _val) => {
if (!err) {
adapter.setState('states.launch', state.val, true);
}
});
break;
default:
//state.val = '"' + state.val + '"';
adapter.log.debug(`Opening app ${state.val} on WebOS TV: ${adapter.config.ip}`);
sendCommand('ssap://system.launcher/launch', { id: state.val }, (err, _val) => {
if (!err) {
adapter.setState('states.launch', state.val, true);
} else {
adapter.log.debug(
`Error opening app ${state.val} on WebOS TV: ${adapter.config.ip}`,
);
}
});
break;
}
break;
case 'states.input':
adapter.log.debug(
`Sending switch to input "${state.val}" command to WebOS TV: ${adapter.config.ip}`,
);
sendCommand('ssap://tv/switchInput', { inputId: state.val }, (err, val) => {
if (!err && val.returnValue) {
adapter.setState('states.input', state.val, true);
}
});
break;
case 'states.raw':
adapter.log.debug(`Sending RAW command api "${state.val}" to WebOS TV: ${adapter.config.ip}`);
try {
const obj = JSON.parse(state.val);
sendCommand(obj.url, obj.cmd, (err, val) => {
if (!err) {
adapter.log.debug(`Response RAW command api ${JSON.stringify(val)}`);
const rawResult = val !== undefined ? JSON.stringify(val) : '';
adapter.setState('states.raw', rawResult, true);
}
});
} catch (e) {
adapter.log.error(`Parse error RAW command api - ${e}`);
}
break;
case 'states.youtube': {
let uri = state.val;
if (!uri) {
return adapter.setState('states.youtube', '', true);
}
if (!~uri.indexOf('http')) {
uri = `https://www.youtube.com/watch?v=${uri}`;
}
sendCommand(
'ssap://system.launcher/launch',
{ id: 'youtube.leanback.v4', contentId: uri },
(err, _val) => {
if (!err) {
adapter.setState('states.youtube', state.val, true);
}
},
);
break;
}
case 'states.drag':
// The event type is 'move' for both moves and drags.
if (dx && dy) {
sendCommand(
'move',
{
dx: dx,
dy: dy,
drag: vals[2] === 'drag' ? 1 : 0,
},
(err, _val) => {
if (!err) {
adapter.setState(id, state.val, true);
}
},
);
}
break;
case 'states.scroll':
if (dx && dy) {
sendCommand('scroll', { dx: dx, dy: dy }, (err, _val) => {
if (!err) {
adapter.setState(id, state.val, true);
}
});
}
break;
case 'states.click':
sendCommand('click', {}, (err, _val) => {
if (!err) {
adapter.setState(id, state.val, true);
}
});
break;
case 'states.soundOutput':
sendCommand(
'ssap://com.webos.service.apiadapter/audio/changeSoundOutput',
{ output: state.val },
(err, _val) => {
if (!err) {
adapter.setState(id, state.val, true);
}
},
);
break;
default:
if (~id.indexOf('remote')) {
adapter.log.debug(`State change "${id}" - VALUE: ${JSON.stringify(state)}`);
const ids = id.split('.');
const key = ids[ids.length - 1].toString().toUpperCase();
sendCommand('button', { name: key }, (err, _val) => {
if (!err) {
adapter.setState(id, state.val, true);
} // ?
});
}
break;
}
}
},
unload: callback => {
try {
adapter.clearTimeout(renewTimeout);
adapter.clearInterval(healthInterval);
if (watchdogTimer) {
adapter.clearInterval(watchdogTimer);
watchdogTimer = null;
}
if (lgtvobj) {
lgtvobj.disconnect();
}
isConnect = false;
} catch {
// ignore errors during shutdown
}
callback();
},
ready: () => {
main();
},
});
adapter = new utils.Adapter(options);
return adapter;
}
function connect(cb) {
hostUrl = `wss://${adapter.config.ip}:3001`;
let reconnect = parseInt(adapter.config.reconnect, 10);
if (!reconnect || isNaN(reconnect) || reconnect < 5000) {
reconnect = 5000;
}
const timeout = parseInt(adapter.config.timeout, 10) || 15000;
lgtvobj = new LGTV({
url: hostUrl,
timeout: timeout,
reconnect: reconnect,
clientKey: clientKey,
saveKey: (key, cb) => {
fs.writeFile(keyfile, key, cb);
},
wsconfig: {
keepalive: true,
keepaliveInterval: 10000,
dropConnectionOnKeepaliveTimeout: false,
keepaliveGracePeriod: 5000,
tlsOptions: {
rejectUnauthorized: false,
},
},
});
lgtvobj.on('connecting', host => {
lastConnectingAt = Date.now();
adapter.log.debug(`Connecting to WebOS TV: ${host}`);
checkConnection();
});
lgtvobj.on('close', e => {
adapter.log.debug(`Connection closed: ${e}`);
checkConnection();
});
lgtvobj.on('prompt', () => {
adapter.log.debug(`Waiting for pairing confirmation on WebOS TV ${adapter.config.ip}`);
});
lgtvobj.on('error', error => {
adapter.log.debug(`Error on connecting or sending command to WebOS TV: ${error}`);
});
lgtvobj.on('connect', (_error, _response) => {
adapter.log.debug('WebOS TV Connected');
isConnect = true;
adapter.setStateChanged('info.connection', true, true);
lgtvobj.subscribe('ssap://audio/getVolume', (err, res) => {
adapter.log.debug(`audio/getVolume: ${JSON.stringify(res)}`);
/*
{"changed":["volume"],"returnValue":true,"cause":"volumeUp","volumeMax":100,"scenario":"mastervolume_tv_speaker","muted":false,"volume":14,"action":"changed","supportvolume"...
{"changed":["muted"],"returnValue":true,"volumeMax":100,"scenario":"mastervolume_tv_speaker","muted":true,"volume":15,"caller":"com.webos.surfacemanager.audio","action":"change..
changed in WebOS 5?
{"volumeStatus":{"cause":"volumeDown","mode":"normal","adjustVolume":true,"activeStatus":true,"muteStatus":false,"volume":7,"soundOutput":"tv_speaker","maxVolume":100}
{"volumeStatus":{"activeStatus":true,"adjustVolume":true,"maxVolume":100,"muteStatus":true,"volume":10,"mode":"normal","soundOutput":"tv_speaker"}
*/
if (res) {
if (res.changed) {
if (~res.changed.indexOf('volume') && res.volume !== undefined && res.volume !== null) {
volume = parseInt(res.volume);
if (Number.isFinite(volume)) {
adapter.setState('states.volume', volume, true);
}
}
if (~res.changed.indexOf('muted') && res.muted !== undefined && res.muted !== null) {
adapter.setState('states.mute', !!res.muted, true);
}
} else if (res.volumeStatus) {
if (res.volumeStatus.volume !== undefined && res.volumeStatus.volume !== null) {
volume = parseInt(res.volumeStatus.volume);
if (Number.isFinite(volume)) {
adapter.setState('states.volume', volume, true);
}
}
if (res.volumeStatus.muteStatus !== undefined && res.volumeStatus.muteStatus !== null) {
adapter.setState('states.mute', !!res.volumeStatus.muteStatus, true);
}
if (res.volumeStatus.soundOutput !== undefined && res.volumeStatus.soundOutput !== null) {
adapter.setState('states.soundOutput', res.volumeStatus.soundOutput || '', true);
}
}
}
});
lgtvobj.request('ssap://tv/getExternalInputList', (err, res) => {
if (!err && res.devices) {
adapter.extendObject('states.input', { common: { states: null } }, () => {
adapter.extendObject('states.input', {
common: { states: inputList(res.devices) },
});
});
}
});
lgtvobj.request('ssap://com.webos.applicationManager/listLaunchPoints', (err, res) => {
if (!err && res.launchPoints) {
adapter.extendObject('states.launch', { common: { states: null } }, () => {
adapter.extendObject('states.launch', {
common: {
states: launchList(res.launchPoints),
},
});
});
}
});
lgtvobj.subscribe('ssap://tv/getCurrentChannel', (err, res) => {
if (!err && res) {
adapter.log.debug(`tv/getCurrentChannel: ${JSON.stringify(res)}`);
if (res.channelNumber !== undefined) {
adapter.setState('states.channel', res.channelNumber || '', true);
}
if (res.channelId !== undefined) {
adapter.setState('states.channelId', res.channelId || '', true);
}
} else {
adapter.log.debug(`ERROR on getCurrentChannel: ${err}`);
}
});
lgtvobj.subscribe('ssap://com.webos.applicationManager/getForegroundAppInfo', (err, res) => {
if (!err && res) {
adapter.log.debug(`DEBUGGING getForegroundAppInfo: ${JSON.stringify(res)}`);
curApp = res.appId || '';
if (!curApp) {
// some TV send empty app first, if they switched on
adapter.setTimeout(function () {
if (!curApp) {
// curApp is not set in meantime
if (healthInterval && !adapter.config.healthInterval) {
adapter.clearInterval(healthInterval);
healthInterval = false; // TV works fine, healthInterval is not longer nessessary
adapter.log.info(
'detect poweroff event, polling not longer nessesary. if you have problems, check settings',
);
}
checkCurApp(); // so TV is off
}
}, 1500);
} else {
checkCurApp();
}
} else {
adapter.log.debug(`ERROR on get input and app: ${err}`);
}
});
lgtvobj.subscribe('ssap://com.webos.service.apiadapter/audio/getSoundOutput', (err, res) => {
if (!err && res) {
adapter.log.debug(`audio/getSoundOutput: ${JSON.stringify(res)}`);
if (res.soundOutput !== undefined) {
adapter.setState('states.soundOutput', res.soundOutput || '', true);
}
} else {
adapter.log.debug(`ERROR on getSoundOutput: ${err}`);
}
});
// Subscribe to each picture setting individually — bundling keys into a
// single request silently drops the whole subscription on TVs that do not
// support every key (varies by webOS version and model). The TV pushes
// some properties only on change, so we additionally fetch the initial
// values via request for every key.
PICTURE_KEYS.forEach(key => {
const cat = 'picture';
const handler = (err, res) => {
if (err) {
adapter.log.debug(`getSystemSettings(${cat}.${key}) error: ${err}`);
return;
}
if (res && res.settings && res.settings[key] !== undefined && res.settings[key] !== null) {
const value = coercePictureValue(key, res.settings[key]);
if (value !== null) {
adapter.log.debug(`getSystemSettings ${cat}.${key}: ${value}`);
adapter.setState(`states.picture.${key}`, value, true);
}
} else {
adapter.log.debug(`getSystemSettings ${cat}.${key} no value: ${JSON.stringify(res).slice(0, 200)}`);
}
};
lgtvobj.subscribe('ssap://settings/getSystemSettings', { category: cat, keys: [key] }, handler);
lgtvobj.request('ssap://settings/getSystemSettings', { category: cat, keys: [key] }, handler);
});
sendCommand('ssap://api/getServiceList', null, (err, val) => {
if (!err) {
adapter.log.debug(`Service list: ${JSON.stringify(val)}`);
}
});
sendCommand('ssap://com.webos.service.update/getCurrentSWInformation', null, (err, val) => {
if (!err) {
adapter.log.debug(`getCurrentSWInformation: ${JSON.stringify(val)}`);
const mac = adapter.config.mac ? adapter.config.mac : val?.device_id;
if (mac !== undefined && mac !== null) {
adapter.setState('states.mac', mac, true);
} else {
adapter.log.info('Skipping states.mac update because device_id is missing');
adapter.setState('states.mac', '', true);
}
}
});
sendCommand('ssap://system/getSystemInfo', null, (err, val) => {
if (!err) {
adapter.log.debug(`getSystemInfo: ${JSON.stringify(val)}`);
if (val?.modelName !== undefined && val.modelName !== null) {
adapter.setState('states.model', val.modelName, true);
} else {
adapter.log.info('Skipping states.model update because modelName is missing');
adapter.setState('states.model', '', true);
}
}
});
cb && cb();
});
lastConnectingAt = Date.now();
if (!watchdogTimer) {
watchdogTimer = adapter.setInterval(checkReconnectWatchdog, WATCHDOG_CHECK_MS);
}
}
const launchList = arr => {
const obj = { livetv: 'Live TV' };
arr.forEach(function (o, _i) {
obj[o.id] = o.title;
});
return obj;
};
const inputList = arr => {
const obj = {};
arr.forEach(function (o, _i) {
obj[o.id] = `${o.label} (${o.id})`;
});
return obj;
};
// Writes a single picture setting via the createAlert + onClick(luna) bridge.
// The direct ssap setSystemSettings path is not exposed on the public web
// socket interface, so we hand the actual luna URI to a system alert which the
// TV executes via its onclose/onfail handlers. The alert is closed
// programmatically right after creation, keeping the popup invisible.
function setPictureSetting(key, value) {
// Boolean-typed picture states (currently only eyeComfortMode) map to
// 'on' / 'off' before crossing the Luna boundary. The state itself is
// typed `boolean` in io-package.json, so we ack the original boolean
// back unchanged and the Admin UI shows a real toggle.
let lunaValue = value;
let ackValue = value;
if (PICTURE_BOOLEAN_KEYS.has(key)) {
const mapped = pictureBoolToLuna(value);
if (mapped === null) {
adapter.log.warn(`set picture.${key}=${value} ignored — value is not a boolean`);
return;
}
lunaValue = mapped;
// Re-coerce so a tolerated 'on'/'true'-string write also acks as boolean.
ackValue = mapped === 'on';
}
const params = { category: setCategoryFor(key), settings: { [key]: lunaValue } };
const lunaUri = 'luna://com.webos.settingsservice/setSystemSettings';
sendCommand(
'ssap://system.notifications/createAlert',
{
title: ' ',
message: ' ',
modal: true,
type: 'confirm',
isSysReq: true,
buttons: [{ label: 'OK', focus: true, buttonType: 'ok', onClick: lunaUri, params }],
onclose: { uri: lunaUri, params },
onfail: { uri: lunaUri, params },
},
(err, val) => {
if (err) {
adapter.log.warn(`set picture.${key}=${lunaValue} failed: ${err}`);
return;
}
adapter.log.debug(`set picture.${key}=${lunaValue}: ${JSON.stringify(val)}`);
adapter.setState(`states.picture.${key}`, ackValue, true);
if (val && val.alertId) {
sendCommand('ssap://system.notifications/closeAlert', { alertId: val.alertId }, () => {});
}
},
);
}
function checkConnection(secondCheck) {
if (secondCheck) {
if (!isConnect) {
adapter.setStateChanged('info.connection', false, true);
adapter.clearInterval(healthInterval);
checkCurApp(true);
}
} else {
isConnect = false;
adapter.setTimeout(checkConnection, 10000, true); //check, if isConnect is changed in 10 sec
}
}
function checkReconnectWatchdog() {
if (isConnect || watchdogProbeInFlight) {
return;
}
const idleMs = Date.now() - lastConnectingAt;
if (idleMs <= WATCHDOG_STUCK_MS) {
return;
}
watchdogProbeInFlight = true;
probeTcpReachable(adapter.config.ip, WATCHDOG_PROBE_PORT, WATCHDOG_PROBE_TIMEOUT_MS)
.then(reachable => {
if (!reachable) {
// TV is off / unreachable — defer the next probe by a full
// window so we don't poll the network every 30s while the TV
// sleeps. The lgtv2 library keeps retrying internally; the
// watchdog only steps in once it sees a stuck handshake.
lastConnectingAt = Date.now();
adapter.log.debug(
`[WATCHDOG] TV ${adapter.config.ip}:${WATCHDOG_PROBE_PORT} not reachable — skipping recreate`,
);
return;
}
adapter.log.warn(
`[WATCHDOG] No reconnect attempt for ${Math.round(idleMs / 1000)}s while disconnected — recreating LGTV instance`,
);
try {
lgtvobj && lgtvobj.disconnect();
} catch (err) {
adapter.log.debug(`[WATCHDOG] disconnect failed: ${err}`);
}
// Suppress retrigger until the new instance has had a chance to settle.
lastConnectingAt = Date.now();
adapter.setTimeout(connect, 1000);
})
.catch(err => {
adapter.log.debug(`[WATCHDOG] probe failed: ${err}`);
})
.finally(() => {
watchdogProbeInFlight = false;
});
}
function checkCurApp(powerOff) {
if (powerOff) {
curApp = '';
}
const isTVon = !!curApp;
adapter.log.debug(curApp ? `cur app is ${curApp}` : 'TV is off');
if (curApp == 'com.webos.app.livetv') {
adapter.setTimeout(() => {
lgtvobj.subscribe('ssap://tv/getCurrentChannel', (err, res) => {
if (!err && res) {
adapter.log.debug(`tv/getCurrentChannel: ${JSON.stringify(res)}`);
if (res.channelNumber !== undefined) {
adapter.setState('states.channel', res.channelNumber || '', true);
}
if (res.channelId !== undefined) {
adapter.setState('states.channelId', res.channelId || '', true);
}
} else {
adapter.log.debug(`ERROR on getCurrentChannel: ${err}`);
}
});
}, 3000);
}
adapter.setStateChanged('states.currentApp', curApp, true);
const inp = curApp.split('.').pop();
if (inp && inp.indexOf('hdmi') == 0) {
adapter.setStateChanged('states.input', `HDMI_${inp[4]}`, true);
adapter.setStateChanged('states.launch', '', true);
} else {
adapter.setStateChanged('states.input', '', true);
adapter.setStateChanged('states.launch', inp || '', true);
}
adapter.setStateChanged('states.power', isTVon, true);
adapter.setStateChanged('states.on', isTVon, true, function (err, stateID, notChanged) {
if (!notChanged) {
// state was changed
adapter.clearTimeout(renewTimeout); // avoid toggeling
if (isTVon) {
// if tv is now switched on ...
adapter.log.debug('renew connection in one minute for stable subscriptions...');
renewTimeout = adapter.setTimeout(() => {
lgtvobj.disconnect();
adapter.setTimeout(lgtvobj.connect, 500, hostUrl);
if (healthInterval !== false) {
healthInterval = adapter.setInterval(
sendCommand,
adapter.config.healthInterval || 60000,
'ssap://com.webos.service.tv.time/getCurrentTime',
null,
(err, _val) => {
adapter.log.debug(`check TV connection: ${err || 'ok'}`);
if (err) {
checkCurApp(true);
}
},
);
}
}, 60000);
}
}
});
}
function sendCommand(cmd, options, cb) {
if (isConnect) {
sendPacket(cmd, options, (_error, response) => {
cb && cb(_error, response);
});
}
}
function sendPacket(cmd, options, cb) {
if (~cmd.indexOf('ssap:') || ~cmd.indexOf('com.')) {
lgtvobj.request(cmd, options, (_error, response) => {
if (_error) {
adapter.log.debug(`ERROR! Response from TV: ${response ? JSON.stringify(response) : _error}`);
}
cb && cb(_error, response);
});
} else {
bypassCertificateValidation();
lgtvobj.getSocket('ssap://com.webos.service.networkinput/getPointerInputSocket', (err, sock) => {
if (!err) {
sock.send(cmd, options);
}
});
}
}
function bypassCertificateValidation() {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
const tls = require('node:tls');
tls.checkServerIdentity = (_servername, _cert) => {
// Skip certificate verification
return undefined;
};
}
function SetVolume(val) {
if (val >= volume + 5) {
let vol = oldvolume;
const interval = adapter.setInterval(() => {
vol = vol + 2;
if (vol >= val) {
vol = val;
adapter.clearInterval(interval);
}
sendCommand('ssap://audio/setVolume', { volume: vol }, (err, _resp) => {
if (!err) {
//
}
});
}, 500);
} else {
sendCommand('ssap://audio/setVolume', { volume: val }, (err, _resp) => {
if (!err) {
//
}
});
}
}
function main() {
if (adapter.config.ip) {
adapter.log.info(`Ready. Configured WebOS TV IP: ${adapter.config.ip}`);
adapter.subscribeStates('*');
const dir = path.join(utils.getAbsoluteDefaultDataDir(), adapter.namespace.replace('.', '_'));
keyfile = path.join(dir, keyfile);
adapter.log.debug(`adapter.config = ${JSON.stringify(adapter.config)}`);
adapter.config.healthInterval = parseInt(adapter.config.healthInterval, 10) || 0;
if (adapter.config.healthInterval < 1) {
healthInterval = false;
} else if (adapter.config.healthInterval < 5000) {
adapter.log.info(`Health-Interval must not be less than 5s; setting adjusted.`);
adapter.config.healthInterval = 5000;
}
if (!fs.existsSync(dir)) {