Last active
February 2, 2025 14:39
-
-
Save soywiz/139c46726d13d60498ad5727f0afb1e8 to your computer and use it in GitHub Desktop.
Discover and control Samsung TVs directly CLI tool via IP as a node tool
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env node | |
///////////////////////////////////////////////////////////////////////////////////////////////////////// | |
// Allows to discover and control Samsung TVs directly from CLI | |
// | |
// Embedded node_wake_on_lan (David Siegel) and samsung-tv-remote (Badisi) npm packages | |
// for convenience so it can be run with just node withput npm. | |
// | |
// requires `npm -g install ws` | |
// | |
// Discovery code, cache and main program - Copyright (c) 2025 soywiz | |
// | |
// Save this file as `control-samsung-tv.ts` and `chmod +x` it | |
///////////////////////////////////////////////////////////////////////////////////////////////////////// | |
// | |
// LICENSES: | |
// | |
// https://github.com/agnat/node_wake_on_lan/tree/master | |
// https://github.com/agnat/node_wake_on_lan/blob/master/LICENSE | |
// MIT License: Copyright (c) 2010 David Siegel | |
// | |
// https://github.com/Badisi/samsung-tv-remote/ | |
// https://github.com/Badisi/samsung-tv-remote/blob/main/LICENSE | |
// MIT License: Copyright (c) 2017 Badisi | |
// | |
///////////////////////////////////////////////////////////////////////////////////////////////////////// | |
import fs from 'fs' | |
import dgram from 'dgram'; | |
import net from 'net'; | |
import os from 'os'; | |
import { existsSync, readFileSync, writeFileSync } from 'fs'; | |
import { exec } from 'child_process'; | |
import { homedir } from 'os'; | |
import { join } from 'path'; | |
import WebSocket from 'ws'; | |
///////////////////////////////////////////////////////////////////////////////////////////////////////// | |
// node_wake_on_lan: https://github.com/agnat/node_wake_on_lan/tree/master | |
///////////////////////////////////////////////////////////////////////////////////////////////////////// | |
//var dgram = require('dgram') | |
// , net = require('net') | |
// , Buffer = require('buffer').Buffer | |
// ; | |
var allocBuffer = Buffer.alloc ? | |
function allocBuffer(s) { return Buffer.alloc(s) } : | |
function allocBuffer(s) { return new Buffer(s) } | |
var mac_bytes = 6; | |
function createMagicPacket(mac: string) { | |
var mac_buffer = allocBuffer(mac_bytes) | |
, i | |
; | |
if (mac.length == 2 * mac_bytes + (mac_bytes - 1)) { | |
mac = mac.replace(new RegExp(mac[2], 'g'), ''); | |
} | |
if (mac.length != 2 * mac_bytes || mac.match(/[^a-fA-F0-9]/)) { | |
throw new Error("malformed MAC address '" + mac + "'"); | |
} | |
for (i = 0; i < mac_bytes; ++i) { | |
mac_buffer[i] = parseInt(mac.substr(2 * i, 2), 16); | |
} | |
var num_macs = 16 | |
, buffer = allocBuffer((1 + num_macs) * mac_bytes); | |
for (i = 0; i < mac_bytes; ++i) { | |
buffer[i] = 0xff; | |
} | |
for (i = 0; i < num_macs; ++i) { | |
mac_buffer.copy(buffer, (i + 1) * mac_bytes, 0, mac_buffer.length) | |
} | |
return buffer; | |
}; | |
function wake(mac, opts, callback) { | |
if (typeof opts === 'function') { | |
callback = opts; | |
opts = undefined; | |
} | |
opts = opts || {}; | |
var address = opts['address'] || '255.255.255.255' | |
, num_packets = opts['num_packets'] || 3 | |
, interval = opts['interval'] || 100 | |
, port = opts['port'] || 9 | |
, magic_packet = createMagicPacket(mac) | |
, socket = dgram.createSocket(net.isIPv6(address) ? 'udp6' : 'udp4') | |
, i = 0 | |
, timer_id | |
; | |
function post_write(error) { | |
if (error || i === num_packets) { | |
try { | |
socket.close(); | |
} catch (ex) { | |
error = error || ex; | |
} | |
if (timer_id) { | |
clearTimeout(timer_id); | |
} | |
if (callback) { | |
callback(error); | |
} | |
} | |
} | |
socket.on('error', post_write); | |
function sendWoL() { | |
i += 1; | |
socket.send(magic_packet, 0, magic_packet.length, port, address, post_write); | |
if (i < num_packets) { | |
timer_id = setTimeout(sendWoL, interval); | |
} else { | |
timer_id = undefined; | |
} | |
} | |
socket.once('listening', function() { | |
socket.setBroadcast(true) | |
}); | |
sendWoL(); | |
} | |
///////////////////////////////////////////////////////////////////////////////////////////////////////// | |
// /node_wake_on_lan | |
///////////////////////////////////////////////////////////////////////////////////////////////////////// | |
///////////////////////////////////////////////////////////////////////////////////////////////////////// | |
// samsung-tv-remote: https://github.com/Badisi/samsung-tv-remote/ | |
///////////////////////////////////////////////////////////////////////////////////////////////////////// | |
export const Keys = { | |
KEY_0: 'KEY_0', | |
KEY_1: 'KEY_1', | |
KEY_2: 'KEY_2', | |
KEY_3: 'KEY_3', | |
KEY_4: 'KEY_4', | |
KEY_5: 'KEY_5', | |
KEY_6: 'KEY_6', | |
KEY_7: 'KEY_7', | |
KEY_8: 'KEY_8', | |
KEY_9: 'KEY_9', | |
KEY_11: 'KEY_11', | |
KEY_12: 'KEY_12', | |
KEY_4_3: 'KEY_4_3', | |
KEY_16_9: 'KEY_16_9', | |
KEY_3SPEED: 'KEY_3SPEED', | |
KEY_AD: 'KEY_AD', | |
KEY_ADDDEL: 'KEY_ADDDEL', | |
KEY_ALT_MHP: 'KEY_ALT_MHP', | |
KEY_ANGLE: 'KEY_ANGLE', | |
KEY_ANTENA: 'KEY_ANTENA', | |
KEY_ANYNET: 'KEY_ANYNET', | |
KEY_ANYVIEW: 'KEY_ANYVIEW', | |
KEY_APP_LIST: 'KEY_APP_LIST', | |
KEY_ASPECT: 'KEY_ASPECT', | |
KEY_AUTO_ARC_ANTENNA_AIR: 'KEY_AUTO_ARC_ANTENNA_AIR', | |
KEY_AUTO_ARC_ANTENNA_CABLE: 'KEY_AUTO_ARC_ANTENNA_CABLE', | |
KEY_AUTO_ARC_ANTENNA_SATELLITE: 'KEY_AUTO_ARC_ANTENNA_SATELLITE', | |
KEY_AUTO_ARC_ANYNET_AUTO_START: 'KEY_AUTO_ARC_ANYNET_AUTO_START', | |
KEY_AUTO_ARC_ANYNET_MODE_OK: 'KEY_AUTO_ARC_ANYNET_MODE_OK', | |
KEY_AUTO_ARC_AUTOCOLOR_FAIL: 'KEY_AUTO_ARC_AUTOCOLOR_FAIL', | |
KEY_AUTO_ARC_AUTOCOLOR_SUCCESS: 'KEY_AUTO_ARC_AUTOCOLOR_SUCCESS', | |
KEY_AUTO_ARC_CAPTION_ENG: 'KEY_AUTO_ARC_CAPTION_ENG', | |
KEY_AUTO_ARC_CAPTION_KOR: 'KEY_AUTO_ARC_CAPTION_KOR', | |
KEY_AUTO_ARC_CAPTION_OFF: 'KEY_AUTO_ARC_CAPTION_OFF', | |
KEY_AUTO_ARC_CAPTION_ON: 'KEY_AUTO_ARC_CAPTION_ON', | |
KEY_AUTO_ARC_C_FORCE_AGING: 'KEY_AUTO_ARC_C_FORCE_AGING', | |
KEY_AUTO_ARC_JACK_IDENT: 'KEY_AUTO_ARC_JACK_IDENT', | |
KEY_AUTO_ARC_LNA_OFF: 'KEY_AUTO_ARC_LNA_OFF', | |
KEY_AUTO_ARC_LNA_ON: 'KEY_AUTO_ARC_LNA_ON', | |
KEY_AUTO_ARC_PIP_CH_CHANGE: 'KEY_AUTO_ARC_PIP_CH_CHANGE', | |
KEY_AUTO_ARC_PIP_DOUBLE: 'KEY_AUTO_ARC_PIP_DOUBLE', | |
KEY_AUTO_ARC_PIP_LARGE: 'KEY_AUTO_ARC_PIP_LARGE', | |
KEY_AUTO_ARC_PIP_LEFT_BOTTOM: 'KEY_AUTO_ARC_PIP_LEFT_BOTTOM', | |
KEY_AUTO_ARC_PIP_LEFT_TOP: 'KEY_AUTO_ARC_PIP_LEFT_TOP', | |
KEY_AUTO_ARC_PIP_RIGHT_BOTTOM: 'KEY_AUTO_ARC_PIP_RIGHT_BOTTOM', | |
KEY_AUTO_ARC_PIP_RIGHT_TOP: 'KEY_AUTO_ARC_PIP_RIGHT_TOP', | |
KEY_AUTO_ARC_PIP_SMALL: 'KEY_AUTO_ARC_PIP_SMALL', | |
KEY_AUTO_ARC_PIP_SOURCE_CHANGE: 'KEY_AUTO_ARC_PIP_SOURCE_CHANGE', | |
KEY_AUTO_ARC_PIP_WIDE: 'KEY_AUTO_ARC_PIP_WIDE', | |
KEY_AUTO_ARC_RESET: 'KEY_AUTO_ARC_RESET', | |
KEY_AUTO_ARC_USBJACK_INSPECT: 'KEY_AUTO_ARC_USBJACK_INSPECT', | |
KEY_AUTO_FORMAT: 'KEY_AUTO_FORMAT', | |
KEY_AUTO_PROGRAM: 'KEY_AUTO_PROGRAM', | |
KEY_AV1: 'KEY_AV1', | |
KEY_AV2: 'KEY_AV2', | |
KEY_AV3: 'KEY_AV3', | |
KEY_BACK_MHP: 'KEY_BACK_MHP', | |
KEY_BOOKMARK: 'KEY_BOOKMARK', | |
KEY_CALLER_ID: 'KEY_CALLER_ID', | |
KEY_CAPTION: 'KEY_CAPTION', | |
KEY_CATV_MODE: 'KEY_CATV_MODE', | |
KEY_CHDOWN: 'KEY_CHDOWN', | |
KEY_CHUP: 'KEY_CHUP', | |
KEY_CH_LIST: 'KEY_CH_LIST', | |
KEY_CLEAR: 'KEY_CLEAR', | |
KEY_CLOCK_DISPLAY: 'KEY_CLOCK_DISPLAY', | |
KEY_COMPONENT1: 'KEY_COMPONENT1', | |
KEY_COMPONENT2: 'KEY_COMPONENT2', | |
KEY_CONTENTS: 'KEY_CONTENTS', | |
KEY_CONVERGENCE: 'KEY_CONVERGENCE', | |
KEY_CONVERT_AUDIO_MAINSUB: 'KEY_CONVERT_AUDIO_MAINSUB', | |
KEY_CUSTOM: 'KEY_CUSTOM', | |
KEY_CYAN: 'KEY_CYAN', | |
KEY_DEVICE_CONNECT: 'KEY_DEVICE_CONNECT', | |
KEY_DISC_MENU: 'KEY_DISC_MENU', | |
KEY_DMA: 'KEY_DMA', | |
KEY_DNET: 'KEY_DNET', | |
KEY_DNI: 'KEY_DNI', | |
KEY_DNS: 'KEY_DNS', | |
KEY_DOOR: 'KEY_DOOR', | |
KEY_DOWN: 'KEY_DOWN', | |
KEY_DSS_MODE: 'KEY_DSS_MODE', | |
KEY_DTV: 'KEY_DTV', | |
KEY_DTV_LINK: 'KEY_DTV_LINK', | |
KEY_DTV_SIGNAL: 'KEY_DTV_SIGNAL', | |
KEY_DVD_MODE: 'KEY_DVD_MODE', | |
KEY_DVI: 'KEY_DVI', | |
KEY_DVR: 'KEY_DVR', | |
KEY_DVR_MENU: 'KEY_DVR_MENU', | |
KEY_DYNAMIC: 'KEY_DYNAMIC', | |
KEY_ENTER: 'KEY_ENTER', | |
KEY_ENTERTAINMENT: 'KEY_ENTERTAINMENT', | |
KEY_ESAVING: 'KEY_ESAVING', | |
KEY_EXT1: 'KEY_EXT1', | |
KEY_EXT2: 'KEY_EXT2', | |
KEY_EXT3: 'KEY_EXT3', | |
KEY_EXT4: 'KEY_EXT4', | |
KEY_EXT5: 'KEY_EXT5', | |
KEY_EXT6: 'KEY_EXT6', | |
KEY_EXT7: 'KEY_EXT7', | |
KEY_EXT8: 'KEY_EXT8', | |
KEY_EXT9: 'KEY_EXT9', | |
KEY_EXT10: 'KEY_EXT10', | |
KEY_EXT11: 'KEY_EXT11', | |
KEY_EXT12: 'KEY_EXT12', | |
KEY_EXT13: 'KEY_EXT13', | |
KEY_EXT14: 'KEY_EXT14', | |
KEY_EXT15: 'KEY_EXT15', | |
KEY_EXT16: 'KEY_EXT16', | |
KEY_EXT17: 'KEY_EXT17', | |
KEY_EXT18: 'KEY_EXT18', | |
KEY_EXT19: 'KEY_EXT19', | |
KEY_EXT20: 'KEY_EXT20', | |
KEY_EXT21: 'KEY_EXT21', | |
KEY_EXT22: 'KEY_EXT22', | |
KEY_EXT23: 'KEY_EXT23', | |
KEY_EXT24: 'KEY_EXT24', | |
KEY_EXT25: 'KEY_EXT25', | |
KEY_EXT26: 'KEY_EXT26', | |
KEY_EXT27: 'KEY_EXT27', | |
KEY_EXT28: 'KEY_EXT28', | |
KEY_EXT29: 'KEY_EXT29', | |
KEY_EXT30: 'KEY_EXT30', | |
KEY_EXT31: 'KEY_EXT31', | |
KEY_EXT32: 'KEY_EXT32', | |
KEY_EXT33: 'KEY_EXT33', | |
KEY_EXT34: 'KEY_EXT34', | |
KEY_EXT35: 'KEY_EXT35', | |
KEY_EXT36: 'KEY_EXT36', | |
KEY_EXT37: 'KEY_EXT37', | |
KEY_EXT38: 'KEY_EXT38', | |
KEY_EXT39: 'KEY_EXT39', | |
KEY_EXT40: 'KEY_EXT40', | |
KEY_EXT41: 'KEY_EXT41', | |
KEY_FACTORY: 'KEY_FACTORY', | |
KEY_FAVCH: 'KEY_FAVCH', | |
KEY_FF: 'KEY_FF', | |
KEY_FF_: 'KEY_FF_', | |
KEY_FM_RADIO: 'KEY_FM_RADIO', | |
KEY_GAME: 'KEY_GAME', | |
KEY_GREEN: 'KEY_GREEN', | |
KEY_GUIDE: 'KEY_GUIDE', | |
KEY_HDMI1: 'KEY_HDMI1', | |
KEY_HDMI2: 'KEY_HDMI2', | |
KEY_HDMI3: 'KEY_HDMI3', | |
KEY_HDMI4: 'KEY_HDMI4', | |
KEY_HDMI: 'KEY_HDMI', | |
KEY_HELP: 'KEY_HELP', | |
KEY_HOME: 'KEY_HOME', | |
KEY_ID_INPUT: 'KEY_ID_INPUT', | |
KEY_ID_SETUP: 'KEY_ID_SETUP', | |
KEY_INFO: 'KEY_INFO', | |
KEY_INSTANT_REPLAY: 'KEY_INSTANT_REPLAY', | |
KEY_LEFT: 'KEY_LEFT', | |
KEY_LINK: 'KEY_LINK', | |
KEY_LIVE: 'KEY_LIVE', | |
KEY_MAGIC_BRIGHT: 'KEY_MAGIC_BRIGHT', | |
KEY_MAGIC_CHANNEL: 'KEY_MAGIC_CHANNEL', | |
KEY_MDC: 'KEY_MDC', | |
KEY_MENU: 'KEY_MENU', | |
KEY_MIC: 'KEY_MIC', | |
KEY_MORE: 'KEY_MORE', | |
KEY_MOVIE1: 'KEY_MOVIE1', | |
KEY_MS: 'KEY_MS', | |
KEY_MTS: 'KEY_MTS', | |
KEY_MUTE: 'KEY_MUTE', | |
KEY_NINE_SEPERATE: 'KEY_NINE_SEPERATE', | |
KEY_OPEN: 'KEY_OPEN', | |
KEY_PANNEL_CHDOWN: 'KEY_PANNEL_CHDOWN', | |
KEY_PANNEL_CHUP: 'KEY_PANNEL_CHUP', | |
KEY_PANNEL_ENTER: 'KEY_PANNEL_ENTER', | |
KEY_PANNEL_MENU: 'KEY_PANNEL_MENU', | |
KEY_PANNEL_POWER: 'KEY_PANNEL_POWER', | |
KEY_PANNEL_SOURCE: 'KEY_PANNEL_SOURCE', | |
KEY_PANNEL_VOLDOW: 'KEY_PANNEL_VOLDOW', | |
KEY_PANNEL_VOLUP: 'KEY_PANNEL_VOLUP', | |
KEY_PANORAMA: 'KEY_PANORAMA', | |
KEY_PAUSE: 'KEY_PAUSE', | |
KEY_PCMODE: 'KEY_PCMODE', | |
KEY_PERPECT_FOCUS: 'KEY_PERPECT_FOCUS', | |
KEY_PICTURE_SIZE: 'KEY_PICTURE_SIZE', | |
KEY_PIP_CHDOWN: 'KEY_PIP_CHDOWN', | |
KEY_PIP_CHUP: 'KEY_PIP_CHUP', | |
KEY_PIP_ONOFF: 'KEY_PIP_ONOFF', | |
KEY_PIP_SCAN: 'KEY_PIP_SCAN', | |
KEY_PIP_SIZE: 'KEY_PIP_SIZE', | |
KEY_PIP_SWAP: 'KEY_PIP_SWAP', | |
KEY_PLAY: 'KEY_PLAY', | |
KEY_PLUS100: 'KEY_PLUS100', | |
KEY_PMODE: 'KEY_PMODE', | |
KEY_POWER: 'KEY_POWER', | |
KEY_POWEROFF: 'KEY_POWEROFF', | |
KEY_POWERON: 'KEY_POWERON', | |
KEY_PRECH: 'KEY_PRECH', | |
KEY_PRINT: 'KEY_PRINT', | |
KEY_PROGRAM: 'KEY_PROGRAM', | |
KEY_QUICK_REPLAY: 'KEY_QUICK_REPLAY', | |
KEY_REC: 'KEY_REC', | |
KEY_RED: 'KEY_RED', | |
KEY_REPEAT: 'KEY_REPEAT', | |
KEY_RESERVED1: 'KEY_RESERVED1', | |
KEY_RETURN: 'KEY_RETURN', | |
KEY_REWIND: 'KEY_REWIND', | |
KEY_REWIND_: 'KEY_REWIND_', | |
KEY_RIGHT: 'KEY_RIGHT', | |
KEY_RSS: 'KEY_RSS', | |
KEY_RSURF: 'KEY_RSURF', | |
KEY_SCALE: 'KEY_SCALE', | |
KEY_SEFFECT: 'KEY_SEFFECT', | |
KEY_SETUP_CLOCK_TIMER: 'KEY_SETUP_CLOCK_TIMER', | |
KEY_SLEEP: 'KEY_SLEEP', | |
KEY_SOURCE: 'KEY_SOURCE', | |
KEY_SRS: 'KEY_SRS', | |
KEY_STANDARD: 'KEY_STANDARD', | |
KEY_STB_MODE: 'KEY_STB_MODE', | |
KEY_STILL_PICTURE: 'KEY_STILL_PICTURE', | |
KEY_STOP: 'KEY_STOP', | |
KEY_SUB_TITLE: 'KEY_SUB_TITLE', | |
KEY_SVIDEO1: 'KEY_SVIDEO1', | |
KEY_SVIDEO2: 'KEY_SVIDEO2', | |
KEY_SVIDEO3: 'KEY_SVIDEO3', | |
KEY_TOOLS: 'KEY_TOOLS', | |
KEY_TOPMENU: 'KEY_TOPMENU', | |
KEY_TTX_MIX: 'KEY_TTX_MIX', | |
KEY_TTX_SUBFACE: 'KEY_TTX_SUBFACE', | |
KEY_TURBO: 'KEY_TURBO', | |
KEY_TV: 'KEY_TV', | |
KEY_TV_MODE: 'KEY_TV_MODE', | |
KEY_UP: 'KEY_UP', | |
KEY_VCHIP: 'KEY_VCHIP', | |
KEY_VCR_MODE: 'KEY_VCR_MODE', | |
KEY_VOLDOWN: 'KEY_VOLDOWN', | |
KEY_VOLUP: 'KEY_VOLUP', | |
KEY_WHEEL_LEFT: 'KEY_WHEEL_LEFT', | |
KEY_WHEEL_RIGHT: 'KEY_WHEEL_RIGHT', | |
KEY_W_LINK: 'KEY_W_LINK', | |
KEY_YELLOW: 'KEY_YELLOW', | |
KEY_ZOOM1: 'KEY_ZOOM1', | |
KEY_ZOOM2: 'KEY_ZOOM2', | |
KEY_ZOOM_IN: 'KEY_ZOOM_IN', | |
KEY_ZOOM_MOVE: 'KEY_ZOOM_MOVE', | |
KEY_ZOOM_OUT: 'KEY_ZOOM_OUT', | |
}; | |
export class SamsungTvLogger { | |
private _enabled = false; | |
public enabled(value: boolean): void { | |
this._enabled = value; | |
} | |
public log(...params: any[]): void { | |
if (this._enabled) { | |
console.log('[SamsungTvRemote]:', ...params); | |
} | |
} | |
public warn(...params: any[]): void { | |
if (this._enabled) { | |
console.error('[SamsungTvRemote]:`\x1b[33m', ...params, '\x1b[0m'); | |
} | |
} | |
public error(...params: any[]): void { | |
if (this._enabled) { | |
console.error('[SamsungTvRemote]:\x1b[31m', 'Error:', ...params, '\x1b[0m'); | |
} | |
} | |
} | |
export interface SamsungTvRemoteOptions { | |
/** | |
* IP address of the TV. | |
*/ | |
ip: string; | |
/** | |
* MAC address of the TV. | |
* Required only when using the 'wakeTV()' api. | |
* | |
* @default 00:00:00:00:00:00 | |
*/ | |
mac?: string, | |
/** | |
* Name under which the TV will recognize your program. | |
* - It will be displayed on TV, the first time you run your program, as a 'device' trying to connect. | |
* - It will also be used by this library to persist a token on the operating system running your program, | |
* so that no further consent are asked by the TV after the first run. | |
* | |
* @default SamsungTvRemote | |
*/ | |
name?: string, | |
/** | |
* Port address used for remote control emulation protocol. | |
* Different ports are used in different TV models. | |
* It could be: 55000 (legacy), 8001 (2016+) or 8002 (2018+). | |
* | |
* @default 8002 | |
*/ | |
port?: number, | |
/** | |
* Milliseconds before the connection to the TV times out. | |
* | |
* @default 1000 | |
*/ | |
timeout?: number; | |
/** | |
* Enables more detailed output. | |
* | |
* @default false | |
*/ | |
debug?: boolean; | |
}; | |
export class SamsungTvRemote { | |
private logger = new SamsungTvLogger(); | |
private options!: Required<SamsungTvRemoteOptions>; | |
private wsURL!: string; | |
private token?: string; | |
constructor(options: SamsungTvRemoteOptions) { | |
if (!options.ip) { | |
throw new Error('[SamsungTvRemote]: TV IP address is required'); | |
} | |
// Initialize | |
this.options = { | |
name: options.name ?? 'SamsungTvRemote', | |
ip: options.ip, | |
mac: options.mac ?? '00:00:00:00:00:00', | |
port: options.port ?? 8002, | |
timeout: options.timeout ?? 1000, | |
debug: options.debug ?? false | |
}; | |
this.logger.enabled(this.options.debug); | |
this.logger.log('Options:', this.options); | |
// Retrieve app token (if previously registered) | |
this.checkAppRegistration(this.options.ip, this.options.port, this.options.name); | |
// Initialize web socket url | |
this.refreshWebSocketURL(); | |
} | |
// --- PUBLIC API(s) --- | |
/** | |
* Send a key to the TV. | |
* | |
* @async | |
* @param {keyof typeof Keys} key The key to be sent | |
* @returns {Promise<void>} A void promise | |
*/ | |
public async sendKey(key: keyof typeof Keys): Promise<void> { | |
if (key) { | |
const command = JSON.stringify({ | |
method: 'ms.remote.control', | |
params: { | |
Cmd: 'Click', | |
DataOfCmd: key, | |
Option: false, | |
TypeOfRemote: 'SendRemoteKey' | |
} | |
}); | |
const ws = await this.connect().catch(err => this.logger.error(err)); | |
if (ws) { | |
this.logger.log('Sending key:', key); | |
if (this.options.port === 8001) { | |
setTimeout(() => ws.send(command), 1000); | |
} else { | |
ws.send(command); | |
setTimeout(() => ws.close(), 250); | |
} | |
} | |
} | |
} | |
/** | |
* Send multiple keys to the TV. | |
* | |
* @async | |
* @param {(keyof typeof Keys)[]} keys An array of keys to be sent | |
* @returns {Promise<void>} A void promise | |
*/ | |
public async sendKeys(keys: (keyof typeof Keys)[]): Promise<void> { | |
for (const key of keys) { | |
await this.sendKey(key); | |
} | |
} | |
/** | |
* Turn the TV on or awaken it from sleep mode (also called WoL - Wake-on-LAN). | |
* The mac address option is required in this case. | |
* | |
* @async | |
* @returns {Promise<void>} A void promise | |
*/ | |
public async wakeTV(): Promise<void> { | |
return new Promise(async (resolve, reject) => { | |
if (!(await this.isTvAlive())) { | |
this.logger.log('Waking TV...'); | |
wake(this.options.mac, { num_packets: 30 }, async (error: Error) => { | |
if (error) { | |
this.logger.error(error); | |
return reject(error); | |
} else { | |
// Gives a little time for the TV to start | |
setTimeout(async () => { | |
if (!(await this.isTvAlive())) { | |
const msg = "TV won't wake up"; | |
this.logger.error(msg); | |
return reject(new Error(`[SamsungTvRemote]: Error: ${msg}`)); | |
} | |
return resolve(); | |
}, 5000); | |
} | |
}); | |
} else { | |
this.logger.log('Waking TV:', 'already up'); | |
return resolve(); | |
} | |
}); | |
} | |
// --- HELPER(s) --- | |
private getCachePath(name = 'badisi-samsung-tv-remote.json'): string { | |
const homeDir = homedir(); | |
switch (process.platform) { | |
case 'darwin': | |
return join(homeDir, 'Library', 'Caches', name); | |
case 'win32': | |
return join(process.env.LOCALAPPDATA ?? join(homeDir, 'AppData', 'Local'), name); | |
default: | |
return join(process.env.XDG_CACHE_HOME ?? join(homeDir, '.cache'), name); | |
} | |
} | |
private getRegisteredApps(): Record<string, Record<string, string>> { | |
const filePath = this.getCachePath(); | |
if (existsSync(filePath)) { | |
return JSON.parse(readFileSync(filePath).toString()); | |
} | |
return {}; | |
} | |
private registerApp(ip: string, port: number, appName: string, appToken: string): void { | |
const filePath = this.getCachePath(); | |
const apps = this.getRegisteredApps(); | |
/** | |
* Saving format was improved in v2.2.0 so that an app can be linked to multiple tv's token: | |
* { appName: { `ip:port`: appToken, `ip2:port2`: appToken } } | |
* However, old format could still be in use and needs to be addressed: | |
* { appName: appToken } | |
*/ | |
if (apps[appName] && typeof apps[appName] === 'string') { | |
apps[appName] = {}; | |
} | |
/* */ | |
apps[appName] ??= {}; | |
apps[appName][`${ip}:${String(port)}`] = appToken; | |
writeFileSync(filePath, JSON.stringify(apps)); | |
} | |
private checkAppRegistration(ip: string, port: number, appName: string): void { | |
const apps = this.getRegisteredApps(); | |
this.token = undefined; | |
if (Object.prototype.hasOwnProperty.call(apps, appName)) { | |
// Old format -> needs to be patched | |
if (typeof apps[appName] === 'string') { | |
this.token = apps[appName] as unknown as string; | |
this.registerApp(ip, port, appName, this.token); | |
} else if (Object.prototype.hasOwnProperty.call(apps[appName], `${ip}:${String(port)}`)) { | |
this.token = apps[appName][`${ip}:${String(port)}`]; | |
} | |
} | |
if (this.token) { | |
this.logger.log('Token found:', this.token); | |
} else { | |
this.logger.warn('No token found:', 'app is not registered yet and will need to be authorized on TV'); | |
} | |
} | |
private refreshWebSocketURL(): void { | |
let url = this.options.port === 8001 ? 'ws' : 'wss'; | |
url += `://${this.options.ip}:${this.options.port}/api/v2/channels/samsung.remote.control`; | |
url += `?name=${Buffer.from(this.options.name).toString('base64')}`; | |
if (this.token) { | |
url += `&token=${this.token}`; | |
} | |
this.wsURL = url; | |
} | |
private async isTvAlive(): Promise<boolean> { | |
return new Promise(resolve => { | |
exec(`ping -c 1 -W 1 ${this.options.ip}`, error => resolve(!!!error)); | |
}); | |
} | |
private connect(): Promise<WebSocket> { | |
return new Promise((resolve, reject) => { | |
this.logger.log('Connecting to TV:', this.wsURL); | |
const ws = new WebSocket(this.wsURL, { | |
timeout: this.options.timeout, | |
rejectUnauthorized: false | |
}); | |
ws.on('error', error => { | |
ws.close(); | |
return reject(error.message); | |
}); | |
ws.on('message', data => { | |
const msg = JSON.parse(data.toString()); | |
if (msg.event === 'ms.channel.connect') { | |
// Register app for next time | |
if (!this.token) { | |
this.token = msg.data.token; | |
this.refreshWebSocketURL(); | |
this.registerApp(this.options.ip, this.options.port, this.options.name, msg.data.token); | |
} | |
return resolve(ws); | |
} else { | |
ws.close(); | |
return reject(msg); | |
} | |
}); | |
}); | |
} | |
} | |
///////////////////////////////////////////////////////////////////////////////////////////////////////// | |
// /samsung-tv-remote | |
///////////////////////////////////////////////////////////////////////////////////////////////////////// | |
interface DeviceInfo { | |
friendlyName: string | |
ip: string | |
mac: string | |
} | |
function getSamsungDevices(timeMs: number = 250): Promise<DeviceInfo[]> { | |
return new Promise((resolve, reject) => { | |
const devices: DeviceInfo[] = [] | |
const socket = dgram.createSocket('udp4'); | |
const ssdpMSearch = [ | |
'M-SEARCH * HTTP/1.1', | |
'HOST: 239.255.255.250:1900', | |
'MAN: "ssdp:discover"', | |
'MX: 10', | |
'ST: urn:dial-multiscreen-org:service:dial:1', | |
'', | |
'' | |
].join('\r\n'); | |
socket.on('listening', () => { | |
socket.setBroadcast(true); | |
socket.setMulticastTTL(2); // TTL = 2 to limit to local network | |
const message = Buffer.from(ssdpMSearch); | |
const multicastAddress = '239.255.255.250'; | |
const port = 1900; | |
// Send M-SEARCH message | |
socket.send(message, 0, message.length, port, multicastAddress, (err) => { | |
if (err) console.error('Error sending:', err); | |
//else console.log('M-SEARCH ent'); | |
}); | |
}); | |
socket.on('message', async (msg, rinfo) => { | |
const response = msg.toString(); | |
if (response.includes('Samsung')) { | |
let obj = {} as any | |
for (const line of response.split("\n")) { | |
const spos = line.indexOf(':') | |
if (spos < 0) continue; | |
const key = line.substring(0, spos).trim().toUpperCase() | |
const value = line.substring(spos + 1).trim() | |
obj[key] = value | |
} | |
let friendlyName = rinfo.address | |
let ipAddress = rinfo.address | |
let macAddress = "00:00:00:00:00:00" | |
if (obj.LOCATION) { | |
try { | |
const result = await (await fetch(obj.LOCATION)).text() | |
const regexp = /<friendlyName>(.*?)<\/friendlyName>/ig | |
friendlyName = [...result.matchAll(regexp)]?.[0]?.[1] | |
} catch (e) { | |
console.error(e) | |
} | |
} | |
const macMatch = response.match(/WAKEUP:\s*MAC=([0-9a-fA-F:]+)/); | |
if (macMatch) { | |
macAddress = macMatch[1]; | |
} | |
devices.push({ friendlyName: friendlyName, ip: ipAddress, mac: macAddress }) | |
//console.log(`Device: friendlyName: ${friendlyName}, ip: ${ipAddress}, mac: ${macAddress}`); | |
} | |
}); | |
socket.bind(); | |
let startTime = Date.now() | |
let interval = setInterval(() => { | |
const elapsedTime = Date.now() - startTime | |
//console.log('interval') | |
if (devices.length > 0 || elapsedTime >= timeMs) { | |
resolve(devices) | |
socket.close(); | |
clearInterval(interval) | |
} | |
}, 25) | |
}) | |
} | |
async function getCachedSamsungDevices(): Promise<DeviceInfo[]> { | |
function getCachePath(name = 'badisi-samsung-tv-remote-device-cache.json') { | |
switch (process.platform) { | |
case 'darwin': return `${os.homedir()}/Library/Caches/${name}` | |
case 'win32': return `${(process.env.LOCALAPPDATA || `${os.homedir()}/AppData/Local`)}/${name}` | |
default: return `${(process.env.XDG_CACHE_HOME || `${os.homedir()}/.cache`)}/${name}` | |
} | |
} | |
const cachePath = getCachePath() | |
let result = {} | |
try { | |
result = JSON.parse(await fs.promises.readFile(cachePath, { encoding: 'utf-8' })) | |
} catch (e) { | |
} | |
if (typeof result !== 'object') result = {}; | |
//console.log(typeof result) | |
for (const device of await getSamsungDevices()) { | |
result[device.mac] = device | |
} | |
await fs.promises.writeFile(cachePath, JSON.stringify(result)) | |
return Object.values(result) | |
} | |
function deviceToString(device: DeviceInfo | undefined) { | |
if (!device) return 'Unknown' | |
return `${device.friendlyName}, ip: ${device.ip}, mac: ${device.mac}` | |
} | |
const main = async () => { | |
const fileHandle = await fs.promises.open('/dev/stdin', 'r'); | |
process.stdin.setRawMode(true); | |
const devices = await getCachedSamsungDevices() | |
if (devices.length == 0) { | |
console.log("Couldn't find any Samsung device") | |
process.exit(-1) | |
} | |
let selectedDevice = devices[0] | |
if (devices.length > 1) { | |
console.log("Select device:") | |
for (let n = 0; n < devices.length; n++) { | |
const device = devices[n] | |
console.log(` ${n}: ${deviceToString(device)}`) | |
} | |
console.log("Waiting for key, q to quit...") | |
while (true) { | |
const buffer = Buffer.alloc(3); | |
const { bytesRead } = await fileHandle.read(buffer, 0, 3, null); | |
const key = buffer.slice(0, bytesRead).toString().replace(/\u0000/g, ''); | |
if (key == 'q') process.exit(-1); | |
const number = parseInt(key) | |
selectedDevice = devices[number] | |
if (selectedDevice) break | |
} | |
} | |
console.log(`Selected ${deviceToString(selectedDevice)}`) | |
const remote = new SamsungTvRemote({ | |
ip: selectedDevice.ip, | |
mac: selectedDevice.mac | |
}); | |
await remote.wakeTV(); | |
console.log('Press any key: q-Shutdown and Quit, f-Force Quit, w-Wake TV, +- -> Volume, Arrows, ENTER, ESC, BACKSPACE'); | |
while (true) { | |
const buffer = Buffer.alloc(3); | |
const { bytesRead } = await fileHandle.read(buffer, 0, 3, null); | |
const key = buffer.slice(0, bytesRead).toString().replace(/\u0000/g, ''); | |
console.log(`Pressed: '${key}', ${[...key].map(c => c.charCodeAt(0).toString(16).padStart(2, '0'))}`); | |
switch (key) { | |
case '0': await remote.sendKey(Keys.KEY_0 as any); break; | |
case '1': await remote.sendKey(Keys.KEY_1 as any); break; | |
case '2': await remote.sendKey(Keys.KEY_2 as any); break; | |
case '3': await remote.sendKey(Keys.KEY_3 as any); break; | |
case '4': await remote.sendKey(Keys.KEY_4 as any); break; | |
case '5': await remote.sendKey(Keys.KEY_5 as any); break; | |
case '6': await remote.sendKey(Keys.KEY_6 as any); break; | |
case '7': await remote.sendKey(Keys.KEY_7 as any); break; | |
case '8': await remote.sendKey(Keys.KEY_8 as any); break; | |
case '9': await remote.sendKey(Keys.KEY_9 as any); break; | |
case '\u001b[A': await remote.sendKey(Keys.KEY_UP as any); break; | |
case '\u001b[B': await remote.sendKey(Keys.KEY_DOWN as any); break; | |
case '\u001b[C': await remote.sendKey(Keys.KEY_RIGHT as any); break; | |
case '\u001b[D': await remote.sendKey(Keys.KEY_LEFT as any); break; | |
case '\u007f': await remote.sendKey(Keys.KEY_BACK_MHP as any); break; | |
case '\u001b': await remote.sendKey(Keys.KEY_HOME as any); break; | |
//case '\x09': await remote.sendKey(Keys.KEY_TOOLS as any); break; | |
case '\r': await remote.sendKey(Keys.KEY_ENTER as any); break; | |
case 'p': await remote.sendKey(Keys.KEY_PLAY as any); break; | |
case '+': await remote.sendKey(Keys.KEY_VOLUP as any); break; | |
case '-': await remote.sendKey(Keys.KEY_VOLDOWN as any); break; | |
case 'w': await remote.sendKey(Keys.KEY_CHUP as any); break; | |
case 's': await remote.sendKey(Keys.KEY_CHDOWN as any); break; | |
case 'q': | |
await remote.sendKeys([Keys.KEY_POWER as any]); | |
console.log('Exiting...'); | |
process.exit(); | |
break; | |
case '\u0003': | |
case 'f': | |
console.log('Force Exiting...'); | |
process.exit(); | |
break; | |
default: | |
[...key].map(it => String.fromCharCode()) | |
break; | |
} | |
} | |
}; | |
main().catch(console.error); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment