├── cli_preview.png ├── bin └── samsung-tv-remote ├── src ├── models │ ├── samsung-app.model.ts │ ├── samsung-device.model.ts │ ├── index.ts │ ├── cache.model.ts │ └── samsung-tv-remote-options.model.ts ├── index.ts ├── logger.ts ├── cache.ts ├── discovery.ts ├── cli.ts ├── keys.ts └── remote.ts ├── .github ├── workflows │ └── ci_release.yml └── ISSUE_TEMPLATE │ ├── 2-feature-request.yml │ ├── 3-doc-issue.yml │ └── 1-bug-report.yml ├── .gitignore ├── tests ├── test.cjs └── test.mjs ├── tsconfig.json ├── LICENSE ├── DEVELOPER.md ├── package.json ├── CODE_OF_CONDUCT.md ├── README.md └── CONTRIBUTING.md /cli_preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Badisi/samsung-tv-remote/HEAD/cli_preview.png -------------------------------------------------------------------------------- /bin/samsung-tv-remote: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | require('../cli.js'); 6 | -------------------------------------------------------------------------------- /src/models/samsung-app.model.ts: -------------------------------------------------------------------------------- 1 | type Token = string; 2 | 3 | export interface SamsungApp { 4 | [IpAndPort: `${string}:${string}`]: Token; 5 | } 6 | -------------------------------------------------------------------------------- /src/models/samsung-device.model.ts: -------------------------------------------------------------------------------- 1 | export interface SamsungDevice { 2 | friendlyName?: string; 3 | ip: string; 4 | mac: string; 5 | } 6 | -------------------------------------------------------------------------------- /.github/workflows/ci_release.yml: -------------------------------------------------------------------------------- 1 | name: Release library 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | ci_release: 9 | uses: badisi/actions/.github/workflows/action.yml@v4 10 | with: 11 | build: true 12 | release: true 13 | -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | export type { Cache } from './cache.model'; 2 | export type { SamsungApp } from './samsung-app.model'; 3 | export type { SamsungDevice } from './samsung-device.model'; 4 | export type { SamsungTvRemoteOptions } from './samsung-tv-remote-options.model'; 5 | -------------------------------------------------------------------------------- /src/models/cache.model.ts: -------------------------------------------------------------------------------- 1 | import type { SamsungApp } from './samsung-app.model'; 2 | import type { SamsungDevice } from './samsung-device.model'; 3 | 4 | export interface Cache { 5 | lastConnectedDevice?: SamsungDevice; 6 | appTokens?: { 7 | [appName: string]: SamsungApp; 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * samsung-tv-remote 3 | * Remote client for Samsung SmartTV starting from 2016 4 | * 5 | * @author Badisi 6 | * @license Released under the MIT license 7 | * 8 | * https://github.com/Badisi/samsung-tv-remote 9 | */ 10 | 11 | export { getAwakeSamsungDevices, getLastConnectedDevice } from './discovery'; 12 | export { Keys } from './keys'; 13 | export type { SamsungDevice, SamsungTvRemoteOptions } from './models'; 14 | export { SamsungTvRemote } from './remote'; 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | /node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | 27 | # misc 28 | /.sass-cache 29 | /connect.lock 30 | /coverage 31 | /libpeerconnection.log 32 | npm-debug.log 33 | yarn-error.log 34 | testem.log 35 | /typings 36 | 37 | # System Files 38 | .DS_Store 39 | Thumbs.db 40 | -------------------------------------------------------------------------------- /tests/test.cjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | const { getAwakeSamsungDevices, Keys, SamsungTvRemote, getLastConnectedDevice } = require('../dist'); 4 | 5 | (async () => { 6 | let device = getLastConnectedDevice(); 7 | if (!device) { 8 | const devices = await getAwakeSamsungDevices(); 9 | if (devices.length) { 10 | device = devices[0]; 11 | } 12 | } 13 | if (device) { 14 | try { 15 | const remote = new SamsungTvRemote({ device }); 16 | await remote.wakeTV(); 17 | await remote.sendKeys([Keys.KEY_RIGHT, Keys.KEY_RIGHT, Keys.KEY_RIGHT, Keys.KEY_RIGHT]); 18 | remote.disconnect(); 19 | } catch (error) { 20 | console.error(error); 21 | } 22 | } 23 | })(); 24 | -------------------------------------------------------------------------------- /tests/test.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { getAwakeSamsungDevices, getLastConnectedDevice, Keys, SamsungTvRemote } from '../dist/index.js'; 4 | 5 | (async () => { 6 | let device = getLastConnectedDevice(); 7 | if (!device) { 8 | const devices = await getAwakeSamsungDevices(); 9 | if (devices.length) { 10 | device = devices[0]; 11 | } 12 | } 13 | if (device) { 14 | try { 15 | const remote = new SamsungTvRemote({ device }); 16 | await remote.wakeTV(); 17 | await remote.sendKeys([Keys.KEY_RIGHT, Keys.KEY_RIGHT, Keys.KEY_RIGHT, Keys.KEY_RIGHT]); 18 | remote.disconnect(); 19 | } catch (error) { 20 | console.error(error); 21 | } 22 | } 23 | })(); 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2-feature-request.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Feature request 2 | description: Suggest a feature for the library 3 | title: "[FEATURE] " 4 | labels: [enhancement, needs triage] 5 | 6 | body: 7 | - type: textarea 8 | attributes: 9 | label: Description 10 | description: A clear and concise description of the problem or missing capability. 11 | validations: 12 | required: true 13 | 14 | - type: textarea 15 | attributes: 16 | label: Proposed solution 17 | description: If you have a solution in mind, please describe it. 18 | validations: 19 | required: true 20 | 21 | - type: textarea 22 | attributes: 23 | label: Alternatives considered 24 | description: Have you considered any alternative solutions or workarounds? 25 | validations: 26 | required: false 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/3-doc-issue.yml: -------------------------------------------------------------------------------- 1 | name: 📚 Docs enhancement 2 | description: File an enhancement or report an issue in the library's documentation 3 | title: "[DOCS] <title>" 4 | labels: [documentation, needs triage] 5 | 6 | body: 7 | - type: checkboxes 8 | attributes: 9 | label: Documentation can be submitted with pull requests 10 | options: 11 | - label: I know that I can edit the docs myself but prefer to file this issue instead 12 | required: true 13 | 14 | - type: input 15 | attributes: 16 | label: Docs URL 17 | description: The URL of the page you'd like to see an enhancement to or report a problem from. 18 | validations: 19 | required: false 20 | 21 | - type: textarea 22 | attributes: 23 | label: Description 24 | description: A clear and concise description of the enhancement or problem. 25 | validations: 26 | required: true 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "src", 4 | "outDir": "dist", 5 | "target": "es2015", 6 | "lib": ["es2022"], 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | "types": ["node"], 10 | "allowJs": true, 11 | "allowSyntheticDefaultImports": true, 12 | "esModuleInterop": true, 13 | "declaration": true, 14 | "inlineSourceMap": false, 15 | "listEmittedFiles": false, 16 | "listFiles": false, 17 | "noFallthroughCasesInSwitch": true, 18 | "pretty": true, 19 | "skipLibCheck": true, 20 | "strict": true, 21 | "traceResolution": false, 22 | "importHelpers": false, 23 | "strictPropertyInitialization": true, 24 | "noUnusedLocals": true 25 | }, 26 | "include": ["src/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Badisi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | export const createLogger = ( 2 | prefix = 'SamsungTvRemote', 3 | level: 'none' | 'debug' | 'info' | 'warn' | 'error' = 'none' 4 | ) => { 5 | const _level = level !== 'none' ? level : (process.env.LOG_LEVEL?.toLowerCase() ?? 'none'); 6 | const _prefix = `\x1b[35m[${prefix}]\x1b[39m:`; 7 | 8 | return { 9 | debug(...params: unknown[]): void { 10 | if (['debug'].includes(_level)) { 11 | console.log(_prefix, ...params); 12 | } 13 | }, 14 | info(...params: unknown[]): void { 15 | if (['debug', 'info'].includes(_level)) { 16 | console.log(_prefix, ...params); 17 | } 18 | }, 19 | warn(...params: unknown[]): void { 20 | if (['debug', 'info', 'warn'].includes(_level)) { 21 | console.error(`${_prefix}\x1b[33m`, ...params, '\x1b[39m'); 22 | } 23 | }, 24 | error(...params: unknown[]): void { 25 | if (['debug', 'info', 'warn', 'error'].includes(_level)) { 26 | console.error(`${_prefix}\x1b[31m`, ...params, '\x1b[39m'); 27 | } 28 | } 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /DEVELOPER.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | This document describes how you can test, build and publish this project. 4 | 5 | ## Prerequisite 6 | 7 | Before you can start you must install and configure the following products on your development machine: 8 | 9 | * [Node.js][nodejs] 10 | * [Git][git] 11 | 12 | You will then need to clone this project and install the required dependencies: 13 | 14 | ```sh 15 | git clone <repository_url> <dir_name> 16 | cd <dir_name> 17 | npm install 18 | ``` 19 | 20 | ## Testing locally 21 | 22 | You can test the library while developing it, as follow: 23 | 24 | 1. Modify one of the test files 25 | 26 | ```sh 27 | cd <library_path>/tests 28 | ``` 29 | 30 | 2. Run the test file 31 | 32 | ```sh 33 | npm run test:cjs (or) npm run test:mjs 34 | ``` 35 | 36 | ## Building the library 37 | 38 | The library will be built in the `./dist` directory. 39 | 40 | ```sh 41 | npm run build 42 | ``` 43 | 44 | ## Publishing to NPM repository 45 | 46 | This project comes with automatic continuous delivery (CD) using *GitHub Actions*. 47 | 48 | 1. Bump the library version in `./package.json` 49 | 2. Push the changes 50 | 3. Create a new [GitHub release](https://github.com/Badisi/samsung-tv-remote/releases/new) 51 | 4. Watch the results in: [Actions](https://github.com/Badisi/samsung-tv-remote/actions) 52 | 53 | 54 | 55 | [git]: https://git-scm.com/ 56 | [nodejs]: https://nodejs.org/ 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "samsung-tv-remote", 3 | "version": "3.0.1", 4 | "description": "Remote client for Samsung SmartTV starting from 2016", 5 | "homepage": "https://github.com/Badisi/samsung-tv-remote", 6 | "license": "MIT", 7 | "author": { 8 | "name": "Badisi" 9 | }, 10 | "type": "commonjs", 11 | "main": "index.js", 12 | "typings": "index.d.ts", 13 | "exports": { 14 | ".": { 15 | "require": "./index.js", 16 | "types": "./index.d.ts", 17 | "default": "./index.js" 18 | }, 19 | "./package.json": "./package.json" 20 | }, 21 | "bin": { 22 | "samsung-tv-remote": "bin/samsung-tv-remote" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "https://github.com/Badisi/samsung-tv-remote" 27 | }, 28 | "keywords": [ 29 | "samsung", 30 | "smarttv", 31 | "remote" 32 | ], 33 | "scripts": { 34 | "ncu": "npx npm-check-updates -i --format=group --packageFile '{,projects/**/}package.json' --no-deprecated", 35 | "build": "node ./build.mjs", 36 | "test:cli": "npm run build && ./dist/bin/samsung-tv-remote --verbose", 37 | "test:cjs": "npm run build && LOG_LEVEL=debug node ./tests/test.cjs", 38 | "test:mjs": "npm run build && LOG_LEVEL=info node ./tests/test.mjs", 39 | "release": "npm publish ./dist --access public" 40 | }, 41 | "dependencies": { 42 | "wake_on_lan": "^1.0.0", 43 | "ws": "^8.18.3" 44 | }, 45 | "devDependencies": { 46 | "@colors/colors": "^1.6.0", 47 | "@types/node": "^24.10.0", 48 | "@types/wake_on_lan": "^0.0.33", 49 | "@types/ws": "^8.18.1", 50 | "cpy": "^12.1.0", 51 | "typescript": "^5.9.3" 52 | }, 53 | "optionalDependencies": { 54 | "bufferutil": "^4.0.9" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/models/samsung-tv-remote-options.model.ts: -------------------------------------------------------------------------------- 1 | import type { SamsungDevice } from './samsung-device.model'; 2 | 3 | export interface SamsungTvRemoteOptions { 4 | /** 5 | * IP address of the TV to connect to. 6 | */ 7 | ip: string; 8 | 9 | /** 10 | * MAC address of the TV to connect to. 11 | * 12 | * Required only when using the 'wakeTV()' api. 13 | * 14 | * @default 00:00:00:00:00:00 15 | */ 16 | mac?: string; 17 | 18 | /** 19 | * A Samsung device to connect to. 20 | * 21 | * To be used in replacement of `ip` and `mac` options. 22 | */ 23 | device?: SamsungDevice; 24 | 25 | /** 26 | * Name under which the TV will recognize your program. 27 | * 28 | * - It will be displayed on TV, the first time you run your program, as a 'device' trying to connect. 29 | * - It will also be used by this library to persist a token on the operating system running your program, 30 | * so that no further consent are asked by the TV after the first run. 31 | * 32 | * @default SamsungTvRemote 33 | */ 34 | name?: string; 35 | 36 | /** 37 | * Port address used for remote control emulation protocol. 38 | * 39 | * Different ports are used in different TV models. 40 | * @example 55000 (legacy), 8001 (2016+) or 8002 (2018+). 41 | * 42 | * @default 8002 43 | */ 44 | port?: number; 45 | 46 | /** 47 | * Delay in milliseconds before the connection to the TV times out. 48 | * 49 | * @default 5000 50 | */ 51 | timeout?: number; 52 | 53 | /** 54 | * Delay in milliseconds between sending key commands. 55 | * 56 | * Some TV models or applications may drop key events if they are sent too quickly. 57 | * Introducing a delay helps ensure reliable key interactions. 58 | * 59 | * @default 60 60 | */ 61 | keysDelay?: number; 62 | } 63 | -------------------------------------------------------------------------------- /src/cache.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, readFileSync, writeFileSync } from 'node:fs'; 2 | import { homedir } from 'node:os'; 3 | import { join } from 'node:path'; 4 | import type { Cache, SamsungApp, SamsungDevice } from './models'; 5 | 6 | export const getDeviceFromCache = (): SamsungDevice | undefined => { 7 | return getCache().lastConnectedDevice; 8 | }; 9 | 10 | export const saveDeviceToCache = (ip: string, mac: string, friendlyName: string): void => { 11 | const cache = getCache(); 12 | cache.lastConnectedDevice = { ip, mac, friendlyName }; 13 | writeFileSync(getCacheFilePath(), JSON.stringify(cache)); 14 | }; 15 | 16 | export const getAppFromCache = (appName: string): SamsungApp | undefined => { 17 | return getCache().appTokens?.[appName]; 18 | }; 19 | 20 | export const saveAppToCache = (ip: string, port: number, appName: string, appToken: string): void => { 21 | const cache = getCache(); 22 | cache.appTokens ??= {}; 23 | cache.appTokens[appName] ??= {}; 24 | cache.appTokens[appName][`${ip}:${String(port)}`] = appToken; 25 | writeFileSync(getCacheFilePath(), JSON.stringify(cache)); 26 | }; 27 | 28 | // --- HELPER(s) --- 29 | 30 | const getCacheFilePath = (name = 'badisi-samsung-tv-remote.json'): string => { 31 | const homeDir = homedir(); 32 | switch (process.platform) { 33 | case 'darwin': 34 | return join(homeDir, 'Library', 'Caches', name); 35 | case 'win32': 36 | return join(process.env.LOCALAPPDATA ?? join(homeDir, 'AppData', 'Local'), name); 37 | default: 38 | return join(process.env.XDG_CACHE_HOME ?? join(homeDir, '.cache'), name); 39 | } 40 | }; 41 | 42 | const getCache = (): Cache => { 43 | try { 44 | const filePath = getCacheFilePath(); 45 | if (existsSync(filePath)) { 46 | return JSON.parse(readFileSync(filePath).toString()); 47 | } 48 | return {} as Cache; 49 | } catch { 50 | return {} as Cache; 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-bug-report.yml: -------------------------------------------------------------------------------- 1 | name: 🐞 Bug report 2 | description: Report a bug in the library 3 | title: "[BUG] <title>" 4 | labels: [bug, needs triage] 5 | 6 | body: 7 | - type: checkboxes 8 | attributes: 9 | label: Is there an existing issue for this? 10 | description: | 11 | Please search open and closed issues before submitting a new one. 12 | Existing issues often contain information about workarounds, resolution or progress updates. 13 | options: 14 | - label: I have searched the existing issues 15 | required: true 16 | 17 | - type: input 18 | attributes: 19 | label: Library version 20 | description: Please make sure you have installed the latest version and verified it is still an issue. 21 | placeholder: latest 22 | validations: 23 | required: true 24 | 25 | - type: textarea 26 | attributes: 27 | label: Description 28 | description: A clear & concise description of what you're experiencing. 29 | validations: 30 | required: true 31 | 32 | - type: textarea 33 | attributes: 34 | label: Steps to reproduce 35 | description: | 36 | Issues that don't have enough info and can't be reproduced will be closed. 37 | Please provide the steps to reproduce the behavior and if applicable create a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) 38 | in a [new repository](https://github.com/new), a [gist](https://gist.github.com) or a [live demo](https://stackblitz.com). 39 | validations: 40 | required: false 41 | 42 | - type: textarea 43 | attributes: 44 | label: Environment 45 | description: | 46 | examples: 47 | - **OS Name**: macOS Monterey (version 12.6.1) 48 | - **System Model Name**: MacBook Pro (16-inch, 2019) 49 | - **npm**: **`npm -v`**: 7.6.3 50 | - **Node.js**: **`node -v`**: 13.14.0 51 | value: | 52 | - **OS Name**: 53 | - **System Model Name**: 54 | - **npm**: 55 | - **Node.js**: 56 | validations: 57 | required: false 58 | -------------------------------------------------------------------------------- /src/discovery.ts: -------------------------------------------------------------------------------- 1 | import { createSocket, type RemoteInfo } from 'node:dgram'; 2 | import { getDeviceFromCache } from './cache'; 3 | import { createLogger } from './logger'; 4 | import type { SamsungDevice } from './models'; 5 | 6 | const logger = createLogger('SamsungTvDiscovery'); 7 | 8 | const SSDP_MSEARCH = [ 9 | 'M-SEARCH * HTTP/1.1', 10 | 'HOST: 239.255.255.250:1900', 11 | 'MAN: "ssdp:discover"', 12 | 'MX: 10', 13 | 'ST: urn:dial-multiscreen-org:service:dial:1', 14 | '', 15 | '' 16 | ].join('\r\n'); 17 | 18 | /** 19 | * Searches for last connected device, if any. 20 | * 21 | * @returns {SamsungDevice | undefined} The device if found, or undefined otherwise 22 | */ 23 | export const getLastConnectedDevice = (): SamsungDevice | undefined => { 24 | logger.info('🔍 Searching for a last connected device...'); 25 | const device = getDeviceFromCache(); 26 | if (!device) { 27 | logger.warn('No last connected device found'); 28 | } else { 29 | logger.info('✅ Found last connected device:', device); 30 | } 31 | return device; 32 | }; 33 | 34 | /** 35 | * Retrieves a list of Samsung devices that are currently awake and reachable on the network. 36 | * 37 | * @async 38 | * @param {number} [timeout=500] The maximum time in milliseconds to wait for the response 39 | * @returns {Promise<SamsungDevice[]>} A promise that resolves with an array of awake Samsung devices 40 | */ 41 | export const getAwakeSamsungDevices = async (timeout: number = 500): Promise<SamsungDevice[]> => { 42 | logger.info('🔍 Searching for awake Samsung devices...'); 43 | 44 | return new Promise(resolve => { 45 | const devices: SamsungDevice[] = []; 46 | const socket = createSocket('udp4'); 47 | 48 | const resolveWithDevices = () => { 49 | if (!devices.length) { 50 | logger.warn('No Samsung devices found'); 51 | } 52 | resolve(devices); 53 | }; 54 | 55 | socket.on('listening', () => { 56 | const address = socket.address(); 57 | logger.debug(`Listening on '${address.address}:${address.port}'...`); 58 | 59 | // Send M-SEARCH message 60 | const message = Buffer.from(SSDP_MSEARCH); 61 | socket.setBroadcast(true); 62 | socket.setMulticastTTL(2); // 2, to limit to local network 63 | logger.debug('Sending M-SEARCH message...'); 64 | socket.send(message, 0, message.length, 1900, '239.255.255.250', error => { 65 | if (error) { 66 | logger.error('Failed:', error); 67 | socket.close(); 68 | resolveWithDevices(); 69 | } 70 | }); 71 | }); 72 | 73 | socket.on('message', async (message: Buffer<ArrayBuffer>, remoteInfo: RemoteInfo): Promise<void> => { 74 | const response = messageToJson(message); 75 | 76 | logger.debug(`Received message from '${remoteInfo.address}:${remoteInfo.port}':\n`, response); 77 | 78 | if (response.SERVER?.includes('Samsung')) { 79 | const device = { 80 | friendlyName: 'Unknown', 81 | ip: remoteInfo.address, 82 | mac: '00:00:00:00:00:00' 83 | }; 84 | 85 | if (response.LOCATION) { 86 | try { 87 | const result = await (await fetch(response.LOCATION)).text(); 88 | const regexp = /<friendlyName>(.*?)<\/friendlyName>/gi; 89 | device.friendlyName = [...result.matchAll(regexp)]?.[0]?.[1]; 90 | } catch { 91 | /** swallow any error as it is not relevant nor blocking */ 92 | } 93 | } 94 | 95 | if (response.WAKEUP) { 96 | const result = response.WAKEUP.match(/\s*MAC=([0-9a-fA-F:]+)/); 97 | if (result) { 98 | device.mac = result[1]; 99 | } 100 | } 101 | 102 | logger.info('✅ Found Samsung device:', device); 103 | devices.push(device); 104 | } 105 | }); 106 | 107 | socket.on('error', error => { 108 | logger.error('Socket error:', error); 109 | socket.close(); 110 | resolveWithDevices(); 111 | }); 112 | 113 | socket.bind(); 114 | 115 | const startTime = Date.now(); 116 | const interval = setInterval(() => { 117 | const elapsedTime = Date.now() - startTime; 118 | if (devices.length > 0 || elapsedTime >= timeout) { 119 | try { 120 | socket.close(); 121 | } catch { 122 | /** in case it was already closed with errors */ 123 | } 124 | clearInterval(interval); 125 | resolveWithDevices(); 126 | } 127 | }, 25); 128 | }); 129 | }; 130 | 131 | // --- HELPER(s) --- 132 | 133 | const messageToJson = (message: Buffer<ArrayBuffer>): Record<string, string> => 134 | message 135 | .toString() 136 | .split('\n') 137 | .reduce( 138 | (acc, line) => { 139 | const spos = line.indexOf(':'); 140 | if (spos < 0) return acc; // If there's no colon, skip the line 141 | const key = line.substring(0, spos).trim().toUpperCase(); 142 | const value = line.substring(spos + 1).trim(); 143 | acc[key] = value; 144 | return acc; 145 | }, 146 | {} as Record<string, string> 147 | ); 148 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported by opening an issue or contacting one or more of the project maintainers. 63 | 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available 126 | at [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | import { clearScreenDown, createInterface, emitKeypressEvents, moveCursor } from 'node:readline'; 2 | import { getAwakeSamsungDevices, getLastConnectedDevice } from './discovery'; 3 | import { Keys } from './keys'; 4 | import type { SamsungDevice } from './models'; 5 | import { SamsungTvRemote } from './remote'; 6 | 7 | interface KeyPressed { 8 | sequence: string; 9 | name?: string; 10 | ctrl?: boolean; 11 | meta?: boolean; 12 | shift?: boolean; 13 | } 14 | 15 | const KEYS_MAP: Record<string, keyof typeof Keys> = { 16 | '0': Keys.KEY_0, 17 | '1': Keys.KEY_1, 18 | '2': Keys.KEY_2, 19 | '3': Keys.KEY_3, 20 | '4': Keys.KEY_4, 21 | '5': Keys.KEY_5, 22 | '6': Keys.KEY_6, 23 | '7': Keys.KEY_7, 24 | '8': Keys.KEY_8, 25 | '9': Keys.KEY_9, 26 | '+': Keys.KEY_VOLUP, 27 | '-': Keys.KEY_VOLDOWN, 28 | p: Keys.KEY_PLAY, 29 | w: Keys.KEY_CHUP, 30 | s: Keys.KEY_CHDOWN, 31 | q: Keys.KEY_POWER, 32 | '\r': Keys.KEY_ENTER, // Return 33 | '\u001b[A': Keys.KEY_UP, // Up 34 | '\u001b[B': Keys.KEY_DOWN, // Down 35 | '\u001b[C': Keys.KEY_RIGHT, // Right 36 | '\u001b[D': Keys.KEY_LEFT, // Left 37 | '\u007f': Keys.KEY_RETURN, // Backspace 38 | '\u001b': Keys.KEY_HOME // Escape 39 | }; 40 | 41 | const cyan = (message: string): string => `\x1b[36m${message}\x1b[0m`; 42 | const gray = (message: string): string => `\x1b[90m${message}\x1b[0m`; 43 | const magenta = (message: string): string => `\x1b[35m${message}\x1b[0m`; 44 | const yellow = (message: string): string => `\x1b[33m${message}\x1b[0m`; 45 | 46 | const deviceLabel = (device: SamsungDevice): string => 47 | `${device.friendlyName ?? 'Unknown'} ${gray(`(ip: ${device.ip}, mac: ${device.mac})`)}`; 48 | 49 | const displayHelp = () => { 50 | console.log(cyan('Usage')); 51 | console.log(` Arrows (${yellow('←/↑/↓/→')})`); 52 | console.log(` Channel (${yellow('w/s')})`); 53 | console.log(` Enter (${yellow('Enter')})`); 54 | console.log(` Home (${yellow('Escape')})`); 55 | console.log(` Numbers (${yellow('[0-9]')})`); 56 | console.log(` Play (${yellow('p')})`); 57 | console.log(` Power (${yellow('q')})`); 58 | console.log(` Return (${yellow('Backspace')})`); 59 | console.log(` Volume (${yellow('+/-')})\n`); 60 | }; 61 | 62 | const askQuestion = (question: string): Promise<string> => 63 | new Promise(resolve => { 64 | const readline = createInterface({ 65 | input: process.stdin, 66 | output: process.stdout 67 | }); 68 | readline.question(question, res => { 69 | const numberOfLines = question.split('\n').length; 70 | moveCursor(process.stdout, 0, -numberOfLines); 71 | clearScreenDown(process.stdout); 72 | resolve(res); 73 | readline.close(); 74 | }); 75 | }); 76 | 77 | const chooseDevice = async (devices: SamsungDevice[]): Promise<number> => { 78 | let question = cyan('? Select device\n'); 79 | devices.forEach((device, index) => { 80 | question += ` ${index + 1}) ${deviceLabel(device)}\n`; 81 | }); 82 | question += '\nYour choice: '; 83 | return Number(await askQuestion(question)); 84 | }; 85 | 86 | (async () => { 87 | if (process.argv.includes('--version') || process.argv.includes('-v')) { 88 | console.log(process.env.npm_package_version); 89 | process.exit(); 90 | } 91 | 92 | console.log(magenta('[SamsungTvRemote]\n')); 93 | displayHelp(); 94 | 95 | try { 96 | let selectedDevice: SamsungDevice | undefined; 97 | let isDeviceAwake = false; 98 | 99 | const devices = await getAwakeSamsungDevices(); 100 | if (devices.length) { 101 | const selectedDeviceIndex = 0; 102 | if (devices.length > 1) { 103 | let deviceIndex = await chooseDevice(devices); 104 | while (typeof deviceIndex !== 'number' || deviceIndex <= 0 || deviceIndex > devices.length) { 105 | deviceIndex = await chooseDevice(devices); 106 | } 107 | deviceIndex--; 108 | } 109 | selectedDevice = devices[selectedDeviceIndex]; 110 | isDeviceAwake = true; 111 | 112 | const label = devices.length > 1 ? 'Selected awake device' : 'Awake device found'; 113 | console.log(`${cyan(`> ${label}:`)} ${deviceLabel(selectedDevice)}`); 114 | } else { 115 | console.log(yellow("> Couldn't find any awake Samsung devices")); 116 | 117 | selectedDevice = getLastConnectedDevice(); 118 | if (selectedDevice) { 119 | console.log(`${cyan('> Last connected device found:')} ${deviceLabel(selectedDevice)}`); 120 | } else { 121 | console.log(yellow("> Couldn't find any last connected device")); 122 | process.exit(-1); 123 | } 124 | } 125 | 126 | const remote = new SamsungTvRemote({ device: selectedDevice, keysDelay: 0 }); 127 | 128 | if (!isDeviceAwake) { 129 | console.log(`${cyan('> Waking TV...')}`); 130 | await remote.wakeTV(); 131 | } 132 | 133 | // 134 | console.log(cyan('\n? Press any key: ')); 135 | createInterface({ input: process.stdin }); // avoid keys to be displayed 136 | emitKeypressEvents(process.stdin); // allow keypress events 137 | if (process.stdin.isTTY) { 138 | process.stdin.setRawMode(true); // allow raw-mode to catch character by character 139 | } 140 | process.stdin.on('keypress', async (_str: string, key: KeyPressed) => { 141 | if (key.sequence in KEYS_MAP) { 142 | console.log(`${cyan('>')} sending...`, gray(KEYS_MAP[key.sequence])); 143 | await remote.sendKey(KEYS_MAP[key.sequence]); 144 | setTimeout(() => { 145 | moveCursor(process.stdout, 0, -1); 146 | clearScreenDown(process.stdout); 147 | }, 250); 148 | } 149 | 150 | if ((key.ctrl && key.name === 'c') || key.name === 'q' || key.name === 'f') { 151 | process.exit(); 152 | } 153 | }); 154 | } catch (error: unknown) { 155 | console.log(''); 156 | console.error(error); 157 | } 158 | })(); 159 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | <h1 align="center"> 2 | samsung-tv-remote 3 | </h1> 4 | 5 | <p align="center"> 6 | <i>📺 NodeJS module to remotely control Samsung SmartTV starting from 2016.</i><br/> 7 | </p> 8 | 9 | <p align="center"> 10 | <a href="https://www.npmjs.com/package/samsung-tv-remote"> 11 | <img src="https://img.shields.io/npm/v/samsung-tv-remote.svg?color=blue&logo=npm" alt="npm version" /></a> 12 | <a href="https://npmcharts.com/compare/samsung-tv-remote?minimal=true"> 13 | <img src="https://img.shields.io/npm/dw/samsung-tv-remote.svg?color=7986CB&logo=npm" alt="npm donwloads" /></a> 14 | <a href="https://github.com/Badisi/samsung-tv-remote/blob/main/LICENSE"> 15 | <img src="https://img.shields.io/npm/l/samsung-tv-remote.svg?color=ff69b4" alt="license" /></a> 16 | </p> 17 | 18 | <p align="center"> 19 | <a href="https://github.com/Badisi/samsung-tv-remote/blob/main/CONTRIBUTING.md#-submitting-a-pull-request-pr"> 20 | <img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs welcome" /></a> 21 | </p> 22 | 23 | <hr/> 24 | 25 | ## Features 26 | 27 | ✅ Support **Samsung SmartTV** from `2016+`<br/> 28 | ✅ Detect any Samsung TVs awake on the network<br/> 29 | ✅ Wake a TV from sleep mode - thanks to `Wake-on-LAN (WoL)`<br/> 30 | ✅ Send `one` or `multiple` keys at once to a TV<br/> 31 | ✅ [`241`][keys] known keys already predefined<br/> 32 | ✅ Works as a `library` and as a `CLI` tool<br/> 33 | 34 | 35 | ## Command line tool 36 | 37 | The CLI utility provides an interactive way to control your TV remotely. 38 | 39 | ```sh 40 | npx samsung-tv-remote 41 | ``` 42 | 43 | ![CLI utility preview][clipreview] 44 | 45 | 46 | ## As a package 47 | 48 | __Installation__ 49 | 50 | ```sh 51 | npm install samsung-tv-remote --save 52 | ``` 53 | 54 | ```sh 55 | yarn add samsung-tv-remote 56 | ``` 57 | 58 | __Example__ 59 | 60 | ```ts 61 | /** CommonJS */ 62 | // const { getAwakeSamsungDevices, Keys, SamsungTvRemote, getLastConnectedDevice } = require('samsung-tv-remote'); 63 | 64 | /** ESM / Typescript */ 65 | import { getAwakeSamsungDevices, getLastConnectedDevice, Keys, SamsungTvRemote } from 'samsung-tv-remote'; 66 | 67 | (async () => { 68 | let device = getLastConnectedDevice(); 69 | if (!device) { 70 | const devices = await getAwakeSamsungDevices(); 71 | if (devices.length) { 72 | device = devices[0]; 73 | } 74 | } 75 | if (device) { 76 | try { 77 | const remote = new SamsungTvRemote({ device }); 78 | await remote.wakeTV(); 79 | await remote.sendKey(Keys.KEY_DOWN); 80 | await remote.sendKeys([Keys.KEY_POWER]) 81 | remote.disconnect(); 82 | } catch (error) { 83 | console.error(error); 84 | } 85 | } 86 | })(); 87 | ``` 88 | 89 | __Options__ 90 | 91 | ```ts 92 | export interface SamsungTvRemoteOptions { 93 | /** 94 | * IP address of the TV to connect to. 95 | */ 96 | ip: string; 97 | 98 | /** 99 | * MAC address of the TV to connect to. 100 | * 101 | * Required only when using the 'wakeTV()' api. 102 | * 103 | * @default 00:00:00:00:00:00 104 | */ 105 | mac?: string; 106 | 107 | /** 108 | * A Samsung device to connect to. 109 | * 110 | * To be used in replacement of `ip` and `mac` options. 111 | */ 112 | device?: SamsungDevice; 113 | 114 | /** 115 | * Name under which the TV will recognize your program. 116 | * 117 | * - It will be displayed on TV, the first time you run your program, as a 'device' trying to connect. 118 | * - It will also be used by this library to persist a token on the operating system running your program, 119 | * so that no further consent are asked by the TV after the first run. 120 | * 121 | * @default SamsungTvRemote 122 | */ 123 | name?: string; 124 | 125 | /** 126 | * Port address used for remote control emulation protocol. 127 | * 128 | * Different ports are used in different TV models. 129 | * @example 55000 (legacy), 8001 (2016+) or 8002 (2018+). 130 | * 131 | * @default 8002 132 | */ 133 | port?: number; 134 | 135 | /** 136 | * Delay in milliseconds before the connection to the TV times out. 137 | * 138 | * @default 5000 139 | */ 140 | timeout?: number; 141 | 142 | /** 143 | * Delay in milliseconds between sending key commands. 144 | * 145 | * Some TV models or applications may drop key events if they are sent too quickly. 146 | * Introducing a delay helps ensure reliable key interactions. 147 | * 148 | * @default 60 149 | */ 150 | keysDelay?: number; 151 | } 152 | ``` 153 | 154 | __Apis__ 155 | 156 | ```ts 157 | class SamsungTvRemote { 158 | /** 159 | * Turns the TV on or awaken it from sleep mode (also called WoL - Wake-on-LAN). 160 | * 161 | * The mac address option is required in this case. 162 | */ 163 | wakeTV(): Promise<void>; 164 | 165 | /** 166 | * Sends a key to the TV. 167 | */ 168 | sendKey(key: keyof typeof Keys): Promise<void>; 169 | 170 | /** 171 | * Sends multiple keys to the TV. 172 | */ 173 | sendKeys(key: (keyof typeof Keys)[]): Promise<void>; 174 | 175 | /** 176 | * Closes the connection to the TV. 177 | * 178 | * It doesn't shut down the TV - it only closes the connection to it. 179 | */ 180 | disconnect(): void; 181 | } 182 | ``` 183 | 184 | __Helpers__ 185 | 186 | ```ts 187 | /** 188 | * Searches for last connected device, if any. 189 | */ 190 | getLastConnectedDevice(): SamsungDevice | undefined; 191 | 192 | /** 193 | * Retrieves a list of Samsung devices that are currently awake and reachable on the network. 194 | */ 195 | getAwakeSamsungDevices(timeout = 500): Promise<SamsungDevice[]>; 196 | ``` 197 | 198 | 199 | ## Debug 200 | 201 | You can enable **verbose mode** to help debug your program. 202 | 203 | Set the `LOG_LEVEL` environment variable to one of the supported values: `none`, `debug`, `info`, `warn`, `error`. 204 | 205 | #### Example 206 | 207 | ```sh 208 | # Run your program in debug mode 209 | LOG_LEVEL=debug npm run yourprogram 210 | ``` 211 | 212 | 213 | ## FAQ 214 | 215 | ### I'm getting a `TypeError: bufferUtil.mask is not a function` 216 | 217 | Under the hood, this library is using [ws](https://github.com/websockets/ws) package and also [bufferutil](https://github.com/websockets/bufferutil) to enhance ws' performances. 218 | 219 | Since `bufferutil` is a binary addon, it may or may not be installed correctly on your current platform due to potential incompatibilities. 220 | 221 | In such cases, using the environment variable `WS_NO_BUFFER_UTIL=1` will be necessary to resolve the issue. 222 | 223 | You can read more [here](https://github.com/websockets/ws/blob/master/doc/ws.md#ws_no_buffer_util). 224 | 225 | 226 | ## Development 227 | 228 | See the [developer docs][developer]. 229 | 230 | 231 | ## Contributing 232 | 233 | #### > Want to Help ? 234 | 235 | Want to file a bug, contribute some code or improve documentation ? Excellent! 236 | 237 | But please read up first on the guidelines for [contributing][contributing], and learn about submission process, coding rules and more. 238 | 239 | #### > Code of Conduct 240 | 241 | Please read and follow the [Code of Conduct][codeofconduct] and help me keep this project open and inclusive. 242 | 243 | 244 | 245 | 246 | [keys]: https://github.com/Badisi/samsung-tv-remote/blob/main/src/keys.ts 247 | [clipreview]: https://github.com/Badisi/samsung-tv-remote/blob/main/cli_preview.png 248 | [developer]: https://github.com/Badisi/samsung-tv-remote/blob/main/DEVELOPER.md 249 | [contributing]: https://github.com/Badisi/samsung-tv-remote/blob/main/CONTRIBUTING.md 250 | [codeofconduct]: https://github.com/Badisi/samsung-tv-remote/blob/main/CODE_OF_CONDUCT.md 251 | -------------------------------------------------------------------------------- /src/keys.ts: -------------------------------------------------------------------------------- 1 | export const Keys = { 2 | KEY_0: 'KEY_0', 3 | KEY_1: 'KEY_1', 4 | KEY_2: 'KEY_2', 5 | KEY_3: 'KEY_3', 6 | KEY_4: 'KEY_4', 7 | KEY_5: 'KEY_5', 8 | KEY_6: 'KEY_6', 9 | KEY_7: 'KEY_7', 10 | KEY_8: 'KEY_8', 11 | KEY_9: 'KEY_9', 12 | KEY_11: 'KEY_11', 13 | KEY_12: 'KEY_12', 14 | KEY_4_3: 'KEY_4_3', 15 | KEY_16_9: 'KEY_16_9', 16 | KEY_3SPEED: 'KEY_3SPEED', 17 | KEY_AD: 'KEY_AD', 18 | KEY_ADDDEL: 'KEY_ADDDEL', 19 | KEY_ALT_MHP: 'KEY_ALT_MHP', 20 | KEY_ANGLE: 'KEY_ANGLE', 21 | KEY_ANTENA: 'KEY_ANTENA', 22 | KEY_ANYNET: 'KEY_ANYNET', 23 | KEY_ANYVIEW: 'KEY_ANYVIEW', 24 | KEY_APP_LIST: 'KEY_APP_LIST', 25 | KEY_ASPECT: 'KEY_ASPECT', 26 | KEY_AUTO_ARC_ANTENNA_AIR: 'KEY_AUTO_ARC_ANTENNA_AIR', 27 | KEY_AUTO_ARC_ANTENNA_CABLE: 'KEY_AUTO_ARC_ANTENNA_CABLE', 28 | KEY_AUTO_ARC_ANTENNA_SATELLITE: 'KEY_AUTO_ARC_ANTENNA_SATELLITE', 29 | KEY_AUTO_ARC_ANYNET_AUTO_START: 'KEY_AUTO_ARC_ANYNET_AUTO_START', 30 | KEY_AUTO_ARC_ANYNET_MODE_OK: 'KEY_AUTO_ARC_ANYNET_MODE_OK', 31 | KEY_AUTO_ARC_AUTOCOLOR_FAIL: 'KEY_AUTO_ARC_AUTOCOLOR_FAIL', 32 | KEY_AUTO_ARC_AUTOCOLOR_SUCCESS: 'KEY_AUTO_ARC_AUTOCOLOR_SUCCESS', 33 | KEY_AUTO_ARC_CAPTION_ENG: 'KEY_AUTO_ARC_CAPTION_ENG', 34 | KEY_AUTO_ARC_CAPTION_KOR: 'KEY_AUTO_ARC_CAPTION_KOR', 35 | KEY_AUTO_ARC_CAPTION_OFF: 'KEY_AUTO_ARC_CAPTION_OFF', 36 | KEY_AUTO_ARC_CAPTION_ON: 'KEY_AUTO_ARC_CAPTION_ON', 37 | KEY_AUTO_ARC_C_FORCE_AGING: 'KEY_AUTO_ARC_C_FORCE_AGING', 38 | KEY_AUTO_ARC_JACK_IDENT: 'KEY_AUTO_ARC_JACK_IDENT', 39 | KEY_AUTO_ARC_LNA_OFF: 'KEY_AUTO_ARC_LNA_OFF', 40 | KEY_AUTO_ARC_LNA_ON: 'KEY_AUTO_ARC_LNA_ON', 41 | KEY_AUTO_ARC_PIP_CH_CHANGE: 'KEY_AUTO_ARC_PIP_CH_CHANGE', 42 | KEY_AUTO_ARC_PIP_DOUBLE: 'KEY_AUTO_ARC_PIP_DOUBLE', 43 | KEY_AUTO_ARC_PIP_LARGE: 'KEY_AUTO_ARC_PIP_LARGE', 44 | KEY_AUTO_ARC_PIP_LEFT_BOTTOM: 'KEY_AUTO_ARC_PIP_LEFT_BOTTOM', 45 | KEY_AUTO_ARC_PIP_LEFT_TOP: 'KEY_AUTO_ARC_PIP_LEFT_TOP', 46 | KEY_AUTO_ARC_PIP_RIGHT_BOTTOM: 'KEY_AUTO_ARC_PIP_RIGHT_BOTTOM', 47 | KEY_AUTO_ARC_PIP_RIGHT_TOP: 'KEY_AUTO_ARC_PIP_RIGHT_TOP', 48 | KEY_AUTO_ARC_PIP_SMALL: 'KEY_AUTO_ARC_PIP_SMALL', 49 | KEY_AUTO_ARC_PIP_SOURCE_CHANGE: 'KEY_AUTO_ARC_PIP_SOURCE_CHANGE', 50 | KEY_AUTO_ARC_PIP_WIDE: 'KEY_AUTO_ARC_PIP_WIDE', 51 | KEY_AUTO_ARC_RESET: 'KEY_AUTO_ARC_RESET', 52 | KEY_AUTO_ARC_USBJACK_INSPECT: 'KEY_AUTO_ARC_USBJACK_INSPECT', 53 | KEY_AUTO_FORMAT: 'KEY_AUTO_FORMAT', 54 | KEY_AUTO_PROGRAM: 'KEY_AUTO_PROGRAM', 55 | KEY_AV1: 'KEY_AV1', 56 | KEY_AV2: 'KEY_AV2', 57 | KEY_AV3: 'KEY_AV3', 58 | KEY_BACK_MHP: 'KEY_BACK_MHP', 59 | KEY_BOOKMARK: 'KEY_BOOKMARK', 60 | KEY_CALLER_ID: 'KEY_CALLER_ID', 61 | KEY_CAPTION: 'KEY_CAPTION', 62 | KEY_CATV_MODE: 'KEY_CATV_MODE', 63 | KEY_CHDOWN: 'KEY_CHDOWN', 64 | KEY_CHUP: 'KEY_CHUP', 65 | KEY_CH_LIST: 'KEY_CH_LIST', 66 | KEY_CLEAR: 'KEY_CLEAR', 67 | KEY_CLOCK_DISPLAY: 'KEY_CLOCK_DISPLAY', 68 | KEY_COMPONENT1: 'KEY_COMPONENT1', 69 | KEY_COMPONENT2: 'KEY_COMPONENT2', 70 | KEY_CONTENTS: 'KEY_CONTENTS', 71 | KEY_CONVERGENCE: 'KEY_CONVERGENCE', 72 | KEY_CONVERT_AUDIO_MAINSUB: 'KEY_CONVERT_AUDIO_MAINSUB', 73 | KEY_CUSTOM: 'KEY_CUSTOM', 74 | KEY_CYAN: 'KEY_CYAN', 75 | KEY_DEVICE_CONNECT: 'KEY_DEVICE_CONNECT', 76 | KEY_DISC_MENU: 'KEY_DISC_MENU', 77 | KEY_DMA: 'KEY_DMA', 78 | KEY_DNET: 'KEY_DNET', 79 | KEY_DNI: 'KEY_DNI', 80 | KEY_DNS: 'KEY_DNS', 81 | KEY_DOOR: 'KEY_DOOR', 82 | KEY_DOWN: 'KEY_DOWN', 83 | KEY_DSS_MODE: 'KEY_DSS_MODE', 84 | KEY_DTV: 'KEY_DTV', 85 | KEY_DTV_LINK: 'KEY_DTV_LINK', 86 | KEY_DTV_SIGNAL: 'KEY_DTV_SIGNAL', 87 | KEY_DVD_MODE: 'KEY_DVD_MODE', 88 | KEY_DVI: 'KEY_DVI', 89 | KEY_DVR: 'KEY_DVR', 90 | KEY_DVR_MENU: 'KEY_DVR_MENU', 91 | KEY_DYNAMIC: 'KEY_DYNAMIC', 92 | KEY_ENTER: 'KEY_ENTER', 93 | KEY_ENTERTAINMENT: 'KEY_ENTERTAINMENT', 94 | KEY_ESAVING: 'KEY_ESAVING', 95 | KEY_EXT1: 'KEY_EXT1', 96 | KEY_EXT2: 'KEY_EXT2', 97 | KEY_EXT3: 'KEY_EXT3', 98 | KEY_EXT4: 'KEY_EXT4', 99 | KEY_EXT5: 'KEY_EXT5', 100 | KEY_EXT6: 'KEY_EXT6', 101 | KEY_EXT7: 'KEY_EXT7', 102 | KEY_EXT8: 'KEY_EXT8', 103 | KEY_EXT9: 'KEY_EXT9', 104 | KEY_EXT10: 'KEY_EXT10', 105 | KEY_EXT11: 'KEY_EXT11', 106 | KEY_EXT12: 'KEY_EXT12', 107 | KEY_EXT13: 'KEY_EXT13', 108 | KEY_EXT14: 'KEY_EXT14', 109 | KEY_EXT15: 'KEY_EXT15', 110 | KEY_EXT16: 'KEY_EXT16', 111 | KEY_EXT17: 'KEY_EXT17', 112 | KEY_EXT18: 'KEY_EXT18', 113 | KEY_EXT19: 'KEY_EXT19', 114 | KEY_EXT20: 'KEY_EXT20', 115 | KEY_EXT21: 'KEY_EXT21', 116 | KEY_EXT22: 'KEY_EXT22', 117 | KEY_EXT23: 'KEY_EXT23', 118 | KEY_EXT24: 'KEY_EXT24', 119 | KEY_EXT25: 'KEY_EXT25', 120 | KEY_EXT26: 'KEY_EXT26', 121 | KEY_EXT27: 'KEY_EXT27', 122 | KEY_EXT28: 'KEY_EXT28', 123 | KEY_EXT29: 'KEY_EXT29', 124 | KEY_EXT30: 'KEY_EXT30', 125 | KEY_EXT31: 'KEY_EXT31', 126 | KEY_EXT32: 'KEY_EXT32', 127 | KEY_EXT33: 'KEY_EXT33', 128 | KEY_EXT34: 'KEY_EXT34', 129 | KEY_EXT35: 'KEY_EXT35', 130 | KEY_EXT36: 'KEY_EXT36', 131 | KEY_EXT37: 'KEY_EXT37', 132 | KEY_EXT38: 'KEY_EXT38', 133 | KEY_EXT39: 'KEY_EXT39', 134 | KEY_EXT40: 'KEY_EXT40', 135 | KEY_EXT41: 'KEY_EXT41', 136 | KEY_FACTORY: 'KEY_FACTORY', 137 | KEY_FAVCH: 'KEY_FAVCH', 138 | KEY_FF: 'KEY_FF', 139 | KEY_FF_: 'KEY_FF_', 140 | KEY_FM_RADIO: 'KEY_FM_RADIO', 141 | KEY_GAME: 'KEY_GAME', 142 | KEY_GREEN: 'KEY_GREEN', 143 | KEY_GUIDE: 'KEY_GUIDE', 144 | KEY_HDMI1: 'KEY_HDMI1', 145 | KEY_HDMI2: 'KEY_HDMI2', 146 | KEY_HDMI3: 'KEY_HDMI3', 147 | KEY_HDMI4: 'KEY_HDMI4', 148 | KEY_HDMI: 'KEY_HDMI', 149 | KEY_HELP: 'KEY_HELP', 150 | KEY_HOME: 'KEY_HOME', 151 | KEY_ID_INPUT: 'KEY_ID_INPUT', 152 | KEY_ID_SETUP: 'KEY_ID_SETUP', 153 | KEY_INFO: 'KEY_INFO', 154 | KEY_INSTANT_REPLAY: 'KEY_INSTANT_REPLAY', 155 | KEY_LEFT: 'KEY_LEFT', 156 | KEY_LINK: 'KEY_LINK', 157 | KEY_LIVE: 'KEY_LIVE', 158 | KEY_MAGIC_BRIGHT: 'KEY_MAGIC_BRIGHT', 159 | KEY_MAGIC_CHANNEL: 'KEY_MAGIC_CHANNEL', 160 | KEY_MDC: 'KEY_MDC', 161 | KEY_MENU: 'KEY_MENU', 162 | KEY_MIC: 'KEY_MIC', 163 | KEY_MORE: 'KEY_MORE', 164 | KEY_MOVIE1: 'KEY_MOVIE1', 165 | KEY_MS: 'KEY_MS', 166 | KEY_MTS: 'KEY_MTS', 167 | KEY_MUTE: 'KEY_MUTE', 168 | KEY_NINE_SEPERATE: 'KEY_NINE_SEPERATE', 169 | KEY_OPEN: 'KEY_OPEN', 170 | KEY_PANNEL_CHDOWN: 'KEY_PANNEL_CHDOWN', 171 | KEY_PANNEL_CHUP: 'KEY_PANNEL_CHUP', 172 | KEY_PANNEL_ENTER: 'KEY_PANNEL_ENTER', 173 | KEY_PANNEL_MENU: 'KEY_PANNEL_MENU', 174 | KEY_PANNEL_POWER: 'KEY_PANNEL_POWER', 175 | KEY_PANNEL_SOURCE: 'KEY_PANNEL_SOURCE', 176 | KEY_PANNEL_VOLDOW: 'KEY_PANNEL_VOLDOW', 177 | KEY_PANNEL_VOLUP: 'KEY_PANNEL_VOLUP', 178 | KEY_PANORAMA: 'KEY_PANORAMA', 179 | KEY_PAUSE: 'KEY_PAUSE', 180 | KEY_PCMODE: 'KEY_PCMODE', 181 | KEY_PERPECT_FOCUS: 'KEY_PERPECT_FOCUS', 182 | KEY_PICTURE_SIZE: 'KEY_PICTURE_SIZE', 183 | KEY_PIP_CHDOWN: 'KEY_PIP_CHDOWN', 184 | KEY_PIP_CHUP: 'KEY_PIP_CHUP', 185 | KEY_PIP_ONOFF: 'KEY_PIP_ONOFF', 186 | KEY_PIP_SCAN: 'KEY_PIP_SCAN', 187 | KEY_PIP_SIZE: 'KEY_PIP_SIZE', 188 | KEY_PIP_SWAP: 'KEY_PIP_SWAP', 189 | KEY_PLAY: 'KEY_PLAY', 190 | KEY_PLUS100: 'KEY_PLUS100', 191 | KEY_PMODE: 'KEY_PMODE', 192 | KEY_POWER: 'KEY_POWER', 193 | KEY_POWEROFF: 'KEY_POWEROFF', 194 | KEY_POWERON: 'KEY_POWERON', 195 | KEY_PRECH: 'KEY_PRECH', 196 | KEY_PRINT: 'KEY_PRINT', 197 | KEY_PROGRAM: 'KEY_PROGRAM', 198 | KEY_QUICK_REPLAY: 'KEY_QUICK_REPLAY', 199 | KEY_REC: 'KEY_REC', 200 | KEY_RED: 'KEY_RED', 201 | KEY_REPEAT: 'KEY_REPEAT', 202 | KEY_RESERVED1: 'KEY_RESERVED1', 203 | KEY_RETURN: 'KEY_RETURN', 204 | KEY_REWIND: 'KEY_REWIND', 205 | KEY_REWIND_: 'KEY_REWIND_', 206 | KEY_RIGHT: 'KEY_RIGHT', 207 | KEY_RSS: 'KEY_RSS', 208 | KEY_RSURF: 'KEY_RSURF', 209 | KEY_SCALE: 'KEY_SCALE', 210 | KEY_SEFFECT: 'KEY_SEFFECT', 211 | KEY_SETUP_CLOCK_TIMER: 'KEY_SETUP_CLOCK_TIMER', 212 | KEY_SLEEP: 'KEY_SLEEP', 213 | KEY_SOURCE: 'KEY_SOURCE', 214 | KEY_SRS: 'KEY_SRS', 215 | KEY_STANDARD: 'KEY_STANDARD', 216 | KEY_STB_MODE: 'KEY_STB_MODE', 217 | KEY_STILL_PICTURE: 'KEY_STILL_PICTURE', 218 | KEY_STOP: 'KEY_STOP', 219 | KEY_SUB_TITLE: 'KEY_SUB_TITLE', 220 | KEY_SVIDEO1: 'KEY_SVIDEO1', 221 | KEY_SVIDEO2: 'KEY_SVIDEO2', 222 | KEY_SVIDEO3: 'KEY_SVIDEO3', 223 | KEY_TOOLS: 'KEY_TOOLS', 224 | KEY_TOPMENU: 'KEY_TOPMENU', 225 | KEY_TTX_MIX: 'KEY_TTX_MIX', 226 | KEY_TTX_SUBFACE: 'KEY_TTX_SUBFACE', 227 | KEY_TURBO: 'KEY_TURBO', 228 | KEY_TV: 'KEY_TV', 229 | KEY_TV_MODE: 'KEY_TV_MODE', 230 | KEY_UP: 'KEY_UP', 231 | KEY_VCHIP: 'KEY_VCHIP', 232 | KEY_VCR_MODE: 'KEY_VCR_MODE', 233 | KEY_VOLDOWN: 'KEY_VOLDOWN', 234 | KEY_VOLUP: 'KEY_VOLUP', 235 | KEY_WHEEL_LEFT: 'KEY_WHEEL_LEFT', 236 | KEY_WHEEL_RIGHT: 'KEY_WHEEL_RIGHT', 237 | KEY_W_LINK: 'KEY_W_LINK', 238 | KEY_YELLOW: 'KEY_YELLOW', 239 | KEY_ZOOM1: 'KEY_ZOOM1', 240 | KEY_ZOOM2: 'KEY_ZOOM2', 241 | KEY_ZOOM_IN: 'KEY_ZOOM_IN', 242 | KEY_ZOOM_MOVE: 'KEY_ZOOM_MOVE', 243 | KEY_ZOOM_OUT: 'KEY_ZOOM_OUT', 244 | } as const; 245 | -------------------------------------------------------------------------------- /src/remote.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'node:child_process'; 2 | import { wake } from 'wake_on_lan'; 3 | import WebSocket from 'ws'; 4 | import { getAppFromCache, saveAppToCache, saveDeviceToCache } from './cache'; 5 | import type { Keys } from './keys'; 6 | import { createLogger } from './logger'; 7 | import type { SamsungTvRemoteOptions } from './models'; 8 | 9 | const logger = createLogger(); 10 | 11 | const DEFAULT_OPTIONS: Required<Omit<SamsungTvRemoteOptions, 'ip' | 'device'>> = { 12 | name: 'SamsungTvRemote', 13 | mac: '00:00:00:00:00:00', 14 | port: 8002, 15 | timeout: 5000, 16 | keysDelay: 60 17 | }; 18 | 19 | export class SamsungTvRemote { 20 | #options!: Required<Omit<SamsungTvRemoteOptions, 'device'>> & Partial<Pick<SamsungTvRemoteOptions, 'device'>>; 21 | #connectingPromise: Promise<void> | null = null; 22 | #webSocketURL!: string; 23 | #webSocket: WebSocket | null = null; 24 | #appToken?: string; 25 | 26 | constructor(options: Omit<SamsungTvRemoteOptions, 'device'>); 27 | constructor(options: Omit<SamsungTvRemoteOptions, 'ip' | 'mac'>); 28 | constructor(options: SamsungTvRemoteOptions) { 29 | // Initialize 30 | this.#options = { 31 | // @ts-expect-error This is made only for keys ordering during the logs 32 | name: undefined, 33 | // @ts-expect-error This is made only for keys ordering during the logs 34 | ip: undefined, 35 | ...DEFAULT_OPTIONS, 36 | ...options 37 | }; 38 | if (options.device) { 39 | this.#options.ip = options.device.ip; 40 | this.#options.mac = options.device.mac; 41 | } 42 | if (!this.#options.ip) { 43 | throw new Error('TV IP address is required'); 44 | } 45 | 46 | logger.info('Remote starting...'); 47 | logger.debug(this.#options); 48 | 49 | // Retrieve app token (if previously registered) 50 | this.#appToken = this.#getAppToken(this.#options.ip, this.#options.port, this.#options.name); 51 | 52 | // Initialize web socket url 53 | this.#refreshWebSocketURL(); 54 | } 55 | 56 | // --- PUBLIC API(s) --- 57 | 58 | /** 59 | * Sends a key to the TV. 60 | * 61 | * @async 62 | * @param {keyof typeof Keys} key The key to be sent 63 | * @returns {Promise<void>} A void promise 64 | */ 65 | public async sendKey(key: keyof typeof Keys): Promise<void> { 66 | if (key) { 67 | await this.#connectToTV(); 68 | 69 | logger.info('📡 Sending key...', key); 70 | this.#webSocket?.send( 71 | JSON.stringify({ 72 | method: 'ms.remote.control', 73 | params: { 74 | Cmd: 'Click', 75 | DataOfCmd: key, 76 | Option: false, 77 | TypeOfRemote: 'SendRemoteKey' 78 | } 79 | }) 80 | ); 81 | 82 | // Gives a delay before the next command 83 | await this.#delay(this.#options.keysDelay); 84 | } 85 | } 86 | 87 | /** 88 | * Sends multiple keys to the TV. 89 | * 90 | * @async 91 | * @param {(keyof typeof Keys)[]} keys An array of keys to be sent 92 | * @returns {Promise<void>} A void promise 93 | */ 94 | public async sendKeys(keys: (keyof typeof Keys)[]): Promise<void> { 95 | for (const key of keys) { 96 | await this.sendKey(key); 97 | } 98 | } 99 | 100 | /** 101 | * Turns the TV on or awaken it from sleep mode (also called WoL - Wake-on-LAN). 102 | * 103 | * The mac address option is required in this case. 104 | * 105 | * @async 106 | * @returns {Promise<void>} A void promise 107 | */ 108 | public async wakeTV(): Promise<void> { 109 | if (await this.#isTvAlive()) { 110 | logger.info('💤 Waking TV... already up'); 111 | return; 112 | } 113 | 114 | logger.info('💤 Waking TV...'); 115 | 116 | if (!this.#options.mac) { 117 | throw new Error('TV mac address is required'); 118 | } 119 | 120 | return new Promise<void>((resolve, reject) => { 121 | wake(this.#options.mac, { num_packets: 30 }, async (error: Error) => { 122 | if (error) { 123 | return reject(error); 124 | } else { 125 | // Gives a little time for the TV to start 126 | setTimeout(async () => { 127 | if (!(await this.#isTvAlive())) { 128 | return reject(new Error("TV won't wake up")); 129 | } 130 | return resolve(); 131 | }, 5000); 132 | } 133 | }); 134 | }); 135 | } 136 | 137 | /** 138 | * Closes the connection to the TV. 139 | * 140 | * It doesn't shut down the TV - it only closes the connection to it. 141 | */ 142 | public disconnect(): void { 143 | logger.info('📺 Disconnecting from TV...'); 144 | this.#disconnectFromTV(); 145 | } 146 | 147 | // --- HELPER(s) --- 148 | 149 | async #delay(ms: number): Promise<void> { 150 | return new Promise(resolve => setTimeout(resolve, ms)); 151 | } 152 | 153 | #getAppToken(ip: string, port: number, appName: string): string | undefined { 154 | let value: string | undefined; 155 | 156 | const app = getAppFromCache(appName); 157 | if (app && typeof app === 'object' && Object.hasOwn(app, `${ip}:${String(port)}`)) { 158 | value = app[`${ip}:${String(port)}`]; 159 | } 160 | 161 | if (value) { 162 | logger.info('✅ App token found:', value); 163 | } else { 164 | logger.warn('No token found: app is not registered yet and will need to be authorized on TV'); 165 | } 166 | 167 | return value; 168 | } 169 | 170 | #refreshWebSocketURL(): void { 171 | let url = this.#options.port === 8001 ? 'ws' : 'wss'; 172 | url += `://${this.#options.ip}:${this.#options.port}/api/v2/channels/samsung.remote.control`; 173 | url += `?name=${Buffer.from(this.#options.name).toString('base64')}`; 174 | if (this.#appToken) { 175 | url += `&token=${this.#appToken}`; 176 | } 177 | this.#webSocketURL = url; 178 | } 179 | 180 | async #isTvAlive(): Promise<boolean> { 181 | return new Promise(resolve => { 182 | exec(`ping -c 1 -W 1 ${this.#options.ip}`, error => resolve(!error)); 183 | }); 184 | } 185 | 186 | #disconnectFromTV(): void { 187 | this.#webSocket?.removeAllListeners(); 188 | this.#webSocket?.close(); 189 | this.#webSocket = null; 190 | this.#connectingPromise = null; 191 | } 192 | 193 | async #connectToTV(): Promise<void> { 194 | // If already connected -> returns immediately 195 | if (this.#webSocket?.readyState === WebSocket.OPEN) { 196 | return Promise.resolve(); 197 | } 198 | 199 | // If already in progress -> returns the promise 200 | if (this.#connectingPromise) { 201 | return this.#connectingPromise; 202 | } 203 | 204 | // Otherwise -> starts new connection 205 | this.#connectingPromise = new Promise((resolve, reject) => { 206 | logger.info('📺 Connecting to TV...'); 207 | logger.debug('Using websocket:', this.#webSocketURL); 208 | 209 | const _webSocket = new WebSocket(this.#webSocketURL, { 210 | timeout: this.#options.timeout, 211 | handshakeTimeout: this.#options.timeout, 212 | rejectUnauthorized: false 213 | }); 214 | 215 | const cleanup = () => { 216 | _webSocket?.removeAllListeners(); 217 | this.#connectingPromise = null; 218 | }; 219 | 220 | _webSocket.on('error', (error: NodeJS.ErrnoException) => { 221 | cleanup(); 222 | this.#disconnectFromTV(); 223 | if (error.code === 'ETIMEDOUT') { 224 | reject(new Error('Connection timed out')); 225 | } else if (error.code === 'EHOSTDOWN') { 226 | reject(new Error('Host is down or service not available')); 227 | } else if (error.code === 'EHOSTUNREACH') { 228 | reject(new Error('Host is unreachable')); 229 | } else { 230 | reject(error); 231 | } 232 | }); 233 | 234 | _webSocket.on('close', () => { 235 | cleanup(); 236 | this.#webSocket = null; 237 | }); 238 | 239 | _webSocket.once('message', data => { 240 | const message = JSON.parse(data.toString()); 241 | if (message.event === 'ms.channel.connect') { 242 | logger.info('✅ Connected to TV'); 243 | 244 | // Save token for next time (if not already in cache) 245 | if (!this.#appToken && message.data?.token) { 246 | this.#appToken = message.data.token; 247 | this.#refreshWebSocketURL(); 248 | saveAppToCache(this.#options.ip, this.#options.port, this.#options.name, message.data.token); 249 | } 250 | 251 | // Save device for next time 252 | const deviceName = this.#options.device?.friendlyName ?? 'Unknown'; 253 | saveDeviceToCache(this.#options.ip, this.#options.mac, deviceName); 254 | 255 | this.#webSocket = _webSocket; 256 | cleanup(); 257 | resolve(); 258 | } else { 259 | throw new Error(`Unexpected handshake message: ${data.toString()}`); 260 | } 261 | }); 262 | }); 263 | 264 | try { 265 | await this.#connectingPromise; 266 | } finally { 267 | this.#connectingPromise = null; 268 | } 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | I would love for you to contribute to this project and help make it even better than it is today! 4 | As a contributor, here are the guidelines I would like you to follow: 5 | 6 | - [Code of Conduct](#coc) 7 | - [Question or Problem?](#question) 8 | - [Issues and Bugs](#issue) 9 | - [Feature Requests](#feature) 10 | - [Submission Guidelines](#submit) 11 | - [Coding Rules](#rules) 12 | - [Commit Message Guidelines](#commit) 13 | 14 | 15 | ## <a name="coc"></a> Code of Conduct 16 | 17 | Please read and follow the [Code of Conduct][coc], and help me keep this project open and inclusive. 18 | 19 | 20 | ## <a name="question"></a> Got a Question or Problem ? 21 | 22 | Please open an issue and add the `question` label to it. 23 | 24 | 25 | ## <a name="issue"></a> Found a Bug ? 26 | 27 | If you find a bug in the source code, you can help by [submitting an issue](#submit-issue) to the [GitHub Repository][github]. 28 | 29 | Even better, you can [submit a Pull Request](#submit-pr) with a fix. 30 | 31 | 32 | ## <a name="feature"></a> Missing a Feature ? 33 | 34 | You can *request* a new feature by [submitting an issue](#submit-issue) to the [GitHub Repository][github]. 35 | 36 | If you would like to *implement* a new feature, please consider the size of the change in order to determine the right steps to proceed: 37 | 38 | * For a **Major Feature**, first open an issue and outline your proposal so that it can be discussed. 39 | This process allows to better coordinate efforts, prevent duplication of work, and help you to craft the change so that it is successfully accepted into the project. 40 | 41 | **Note**: Adding a new topic to the documentation, or significantly re-writing a topic, counts as a major feature. 42 | 43 | * **Small Features** can be crafted and directly [submitted as a Pull Request](#submit-pr). 44 | 45 | 46 | ## <a name="submit"></a> Submission Guidelines 47 | 48 | ### <a name="submit-issue"></a> Submitting an Issue 49 | 50 | Before you submit an issue, please search the issue tracker, maybe an issue for your problem already exists and the discussion might inform you of workarounds readily available. 51 | 52 | I want to fix all the issues as soon as possible, but before fixing a bug I need to reproduce and confirm it. 53 | In order to reproduce bugs, I require that you provide a minimal reproduction. 54 | Having a minimal reproducible scenario gives me a wealth of important information without going back and forth to you with additional questions. 55 | 56 | A minimal reproduction allows me to quickly confirm a bug (or point out a coding problem) as well as confirm that I am fixing the right problem. 57 | 58 | I require a minimal reproduction to save maintainers' time and ultimately be able to fix more bugs. 59 | Often, developers find coding problems themselves while preparing a minimal reproduction. 60 | I understand that sometimes it might be hard to extract essential bits of code from a larger codebase but I really need to isolate the problem before I can fix it. 61 | 62 | Unfortunately, I'm not able to investigate / fix bugs without a minimal reproduction, so if I don't hear back from you, I am going to close an issue that doesn't have enough info to be reproduced. 63 | 64 | You can file new issues by selecting and filling out the *issue template* from the [new issue templates][issue-templates]. 65 | 66 | 67 | ### <a name="submit-pr"></a> Submitting a Pull Request (PR) 68 | 69 | Before you submit your Pull Request (PR) consider the following guidelines: 70 | 71 | 1. Search [GitHub][github-pr] for an open or closed PR that relates to your submission. 72 | You don't want to duplicate existing efforts. 73 | 74 | 2. Be sure that an issue describes the problem you're fixing, or documents the design for the feature you'd like to add. 75 | Discussing the design upfront helps to ensure that I'm ready to accept your work. 76 | 77 | 3. Fork this repository. 78 | 79 | 4. Make your changes in a new git branch: 80 | 81 | ```sh 82 | git checkout -b my-fix-branch master 83 | ``` 84 | 85 | 5. Create your patch, **including appropriate test cases**. 86 | 87 | 6. Follow the [Coding Rules](#rules). 88 | 89 | 7. Run a full test suite and ensure that all tests pass. 90 | 91 | 8. Commit your changes using a descriptive commit message that follows the [commit message conventions](#commit). 92 | Adherence to these conventions is necessary because release notes are automatically generated from these messages. 93 | 94 | ```sh 95 | git commit --all 96 | ``` 97 | Note: the optional commit `-a` command line option will automatically "add" and "rm" edited files. 98 | 99 | 9. Push your branch to GitHub: 100 | 101 | ```sh 102 | git push origin my-fix-branch 103 | ``` 104 | 105 | 10. In GitHub, send a pull request to `develop` branch. 106 | 107 | ### Reviewing a Pull Request 108 | 109 | Pull requests may not be accepted from community members who haven't been good citizens of the community. 110 | 111 | Such behavior includes not following the [code of conduct](#coc) and applies within or outside of this repository. 112 | 113 | #### Addressing review feedback 114 | 115 | If I ask for changes via code reviews then: 116 | 117 | 1. Make the required updates to the code. 118 | 119 | 2. Re-run the test suites to ensure tests are still passing. 120 | 121 | 3. Create a fixup commit and push to your GitHub repository (this will update your Pull Request): 122 | 123 | ```sh 124 | git commit --all --fixup HEAD 125 | git push 126 | ``` 127 | 128 | That's it! Thank you for your contribution! 129 | 130 | #### Updating the commit message 131 | 132 | A reviewer might often suggest changes to a commit message (for example, to add more context for a change or adhere to the [commit message guidelines](#commit)). 133 | 134 | In order to update the commit message of the last commit on your branch: 135 | 136 | 1. Check out your branch: 137 | 138 | ```sh 139 | git checkout my-fix-branch 140 | ``` 141 | 142 | 2. Amend the last commit and modify the commit message: 143 | 144 | ```sh 145 | git commit --amend 146 | ``` 147 | 148 | 3. Push to your GitHub repository: 149 | 150 | ```sh 151 | git push --force-with-lease 152 | ``` 153 | 154 | > NOTE:<br /> 155 | > If you need to update the commit message of an earlier commit, you can use `git rebase` in interactive mode. 156 | > See the [git docs](https://git-scm.com/docs/git-rebase#_interactive_mode) for more details. 157 | 158 | #### After your pull request is merged 159 | 160 | After your pull request is merged, you can safely delete your branch and pull the changes from the main (upstream) repository: 161 | 162 | * Delete the remote branch on GitHub either through the GitHub web UI or your local shell as follows: 163 | 164 | ```sh 165 | git push origin --delete my-fix-branch 166 | ``` 167 | 168 | * Check out the master branch: 169 | 170 | ```sh 171 | git checkout master -f 172 | ``` 173 | 174 | * Delete the local branch: 175 | 176 | ```sh 177 | git branch -D my-fix-branch 178 | ``` 179 | 180 | * Update your master with the latest upstream version: 181 | 182 | ```sh 183 | git pull --ff upstream master 184 | ``` 185 | 186 | 187 | ## <a name="rules"></a> Coding Rules 188 | 189 | To ensure consistency throughout the source code, keep these rules in mind as you are working: 190 | 191 | * All features or bug fixes **must be tested** by one or more specs (unit-tests) 192 | * All public API methods **must be documented** 193 | * I follow [Google's JavaScript Style Guide][js-style-guide], but wrap all code at **100 characters** 194 | 195 | 196 | ## <a name="commit"></a> Commit Message Format 197 | 198 | *This specification is inspired by and supersedes the [Conventional Commits message format][commit-message-format].* 199 | 200 | There are very precise rules over how Git commit messages must be formatted. 201 | 202 | This format leads to **easier to read commit history**. 203 | 204 | Each commit message consists of a **header**, a **body**, and a **footer**. 205 | 206 | 207 | ``` 208 | <header> 209 | <BLANK LINE> 210 | <body> 211 | <BLANK LINE> 212 | <footer> 213 | ``` 214 | 215 | The `header` is mandatory and must conform to the [Commit Message Header](#commit-header) format. 216 | 217 | The `body` is mandatory for all commits except for those of type "docs". When the body is present it must be at least 20 characters long and must conform to the [Commit Message Body](#commit-body) format. 218 | 219 | The `footer` is optional. The [Commit Message Footer](#commit-footer) format describes what the footer is used for and the structure it must have. 220 | 221 | Any line of the commit message cannot be longer than 100 characters. 222 | 223 | 224 | #### <a name="commit-header"></a> Commit Message Header 225 | 226 | ``` 227 | <type>(<scope>): <short summary> 228 | │ │ │ 229 | │ │ └─⫸ Summary in present tense. Not capitalized. No period at the end. 230 | │ │ 231 | │ └─⫸ Commit Scope (optional): provide additional contextual information 232 | │ 233 | └─⫸ Commit Type: build|ci|docs|feat|fix|perf|refactor|test 234 | ``` 235 | 236 | 237 | ##### Type 238 | 239 | Must be one of the following: 240 | 241 | * **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm) 242 | * **ci**: Changes to CI configuration files and scripts (example scopes: Circle, BrowserStack, SauceLabs) 243 | * **docs**: Documentation only changes 244 | * **feat**: A new feature 245 | * **fix**: A bug fix 246 | * **perf**: A code change that improves performance 247 | * **refactor**: A code change that neither fixes a bug nor adds a feature 248 | * **test**: Adding missing tests or correcting existing tests 249 | 250 | 251 | ##### Scope 252 | 253 | Scope can be anything specifying place of the commit change. 254 | 255 | 256 | ##### Summary 257 | 258 | Use the summary field to provide a succinct description of the change: 259 | 260 | * use the imperative, present tense: "change" not "changed" nor "changes" 261 | * don't capitalize the first letter 262 | * no dot (.) at the end 263 | 264 | 265 | #### <a name="commit-body"></a> Commit Message Body 266 | 267 | Just as in the summary, use the imperative, present tense: "fix" not "fixed" nor "fixes". 268 | 269 | Explain the motivation for the change in the commit message body. This commit message should explain _why_ you are making the change. 270 | 271 | You can include a comparison of the previous behavior with the new behavior in order to illustrate the impact of the change. 272 | 273 | 274 | #### <a name="commit-footer"></a> Commit Message Footer 275 | 276 | The footer can contain information about breaking changes and is also the place to reference GitHub issues, Jira tickets, and other PRs that this commit closes or is related to. 277 | 278 | ``` 279 | BREAKING CHANGE: <breaking change summary> 280 | <BLANK LINE> 281 | <breaking change description + migration instructions> 282 | <BLANK LINE> 283 | <BLANK LINE> 284 | Fixes #<issue number> 285 | ``` 286 | 287 | Breaking Change section should start with the phrase `BREAKING CHANGE: ` followed by a summary of the breaking change, a blank line, and a detailed description of the breaking change that also includes migration instructions. 288 | 289 | 290 | ### Revert commits 291 | 292 | If the commit reverts a previous commit, it should begin with `revert: `, followed by the header of the reverted commit. 293 | 294 | The content of the commit message body should contain: 295 | 296 | * information about the SHA of the commit being reverted in the following format: `This reverts commit <SHA>` 297 | * a clear description of the reason for reverting the commit message 298 | 299 | 300 | 301 | 302 | [coc]: https://github.com/Badisi/samsung-tv-remote/blob/main/CODE_OF_CONDUCT.md 303 | [github]: https://github.com/Badisi/samsung-tv-remote 304 | [issue-templates]: https://github.com/Badisi/samsung-tv-remote/issues/new/choose 305 | [github-pr]: https://github.com/Badisi/samsung-tv-remote/pulls 306 | [js-style-guide]: https://google.github.io/styleguide/jsguide.html 307 | [commit-message-format]: https://www.conventionalcommits.org/en/v1.0.0 308 | --------------------------------------------------------------------------------