├── 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] "
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
16 | cd
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 /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] "
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} A promise that resolves with an array of awake Samsung devices
40 | */
41 | export const getAwakeSamsungDevices = async (timeout: number = 500): Promise => {
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, remoteInfo: RemoteInfo): Promise => {
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>/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): Record =>
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
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 = {
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 =>
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 => {
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 |
2 | samsung-tv-remote
3 |
4 |
5 |
6 | 📺 NodeJS module to remotely control Samsung SmartTV starting from 2016.
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | ## Features
26 |
27 | ✅ Support **Samsung SmartTV** from `2016+`
28 | ✅ Detect any Samsung TVs awake on the network
29 | ✅ Wake a TV from sleep mode - thanks to `Wake-on-LAN (WoL)`
30 | ✅ Send `one` or `multiple` keys at once to a TV
31 | ✅ [`241`][keys] known keys already predefined
32 | ✅ Works as a `library` and as a `CLI` tool
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;
164 |
165 | /**
166 | * Sends a key to the TV.
167 | */
168 | sendKey(key: keyof typeof Keys): Promise;
169 |
170 | /**
171 | * Sends multiple keys to the TV.
172 | */
173 | sendKeys(key: (keyof typeof Keys)[]): Promise;
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;
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> = {
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> & Partial>;
21 | #connectingPromise: Promise | null = null;
22 | #webSocketURL!: string;
23 | #webSocket: WebSocket | null = null;
24 | #appToken?: string;
25 |
26 | constructor(options: Omit);
27 | constructor(options: Omit);
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} A void promise
64 | */
65 | public async sendKey(key: keyof typeof Keys): Promise {
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} A void promise
93 | */
94 | public async sendKeys(keys: (keyof typeof Keys)[]): Promise {
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} A void promise
107 | */
108 | public async wakeTV(): Promise {
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((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 {
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 {
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 {
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 | ## 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 | ## Got a Question or Problem ?
21 |
22 | Please open an issue and add the `question` label to it.
23 |
24 |
25 | ## 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 | ## 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 | ## Submission Guidelines
47 |
48 | ### 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 | ### 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:
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 | ## 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 | ## 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 |
209 |
210 |
211 |
212 |