Skip to content

Instantly share code, notes, and snippets.

@soywiz
Last active February 2, 2025 14:39
Show Gist options
  • Save soywiz/139c46726d13d60498ad5727f0afb1e8 to your computer and use it in GitHub Desktop.
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
#!/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