UNPKG

iobroker.lgtv

Version:
1,086 lines (1,026 loc) 51 kB
'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)) {