├── index.js ├── commitlint.config.js ├── .prettierrc ├── .gitignore ├── .travis.yml ├── jest.config.js ├── scripts ├── sample-config.json └── setup.js ├── __mocks__ ├── node-ssdp.js ├── fs.js └── roku-client.js ├── .eslintrc.js ├── bin └── setup ├── LICENSE ├── package.json ├── src ├── setup.js ├── __tests__ │ ├── setup.test.js │ └── homebridge-roku.test.js └── homebridge-roku.js ├── CHANGELOG.md └── README.md /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./src/homebridge-roku'); 2 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | }; 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "singleQuote": true, 4 | "proseWrap": "always" 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /coverage 3 | /config.json 4 | /persist 5 | logs 6 | *.log 7 | npm-debug.log* 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "10" 4 | - "12" 5 | cache: 6 | directories: 7 | - "node_modules" 8 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverage: true, 3 | collectCoverageFrom: ['src/**/*.js'], 4 | /* 5 | coverageThreshold: { 6 | global: { 7 | branches: 90, 8 | lines: 95, 9 | } 10 | } 11 | */ 12 | }; 13 | -------------------------------------------------------------------------------- /scripts/sample-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "bridge": { 3 | "name": "Homebridge", 4 | "username": "CC:22:3D:E3:CE:30", 5 | "port": 51826, 6 | "pin": "031-45-154" 7 | }, 8 | "description": "Sample Config", 9 | "accessories": [] 10 | } 11 | -------------------------------------------------------------------------------- /__mocks__/node-ssdp.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | 'use strict'; 4 | 5 | const nodeSSDP = jest.genMockFromModule('node-ssdp'); 6 | const { EventEmitter } = require('events'); 7 | 8 | const HEADERS = {}; 9 | 10 | class Client extends EventEmitter { 11 | search(key) { 12 | setImmediate(() => { 13 | this.emit('response', HEADERS[key]); 14 | }); 15 | } 16 | } 17 | 18 | function __setResponseHeaders(key, headers) { 19 | HEADERS[key] = headers; 20 | } 21 | 22 | nodeSSDP.Client = Client; 23 | nodeSSDP.__setResponseHeaders = __setResponseHeaders; 24 | 25 | module.exports = nodeSSDP; 26 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['airbnb-base', 'plugin:prettier/recommended'], 3 | 4 | parserOptions: { 5 | sourceType: 'script', 6 | }, 7 | 8 | rules: { 9 | 'max-classes-per-file': 'off', 10 | 'no-console': 'off', 11 | 'no-underscore-dangle': 'off', 12 | 'no-plusplus': 'off', // I wish python had this, don't take it away from js 13 | strict: 'off', 14 | }, 15 | 16 | overrides: [ 17 | { 18 | files: ['*.test.js'], 19 | env: { 20 | jest: true, 21 | }, 22 | rules: { 23 | 'no-new': 'off', 24 | }, 25 | }, 26 | ], 27 | }; 28 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | const { 6 | generateConfig, 7 | mergeConfigWithMaster, 8 | HOMEBRIDGE_CONFIG, 9 | } = require('../src/setup'); 10 | 11 | const args = process.argv.slice(2); 12 | const shouldMerge = args[0] === '--merge'; 13 | 14 | generateConfig() 15 | .then((config) => { 16 | if (shouldMerge) { 17 | mergeConfigWithMaster(config); 18 | console.log(`Added roku accessory configuration to ${HOMEBRIDGE_CONFIG}`); 19 | } else { 20 | console.log(JSON.stringify(config, null, 4)); 21 | } 22 | }) 23 | .catch((err) => { 24 | console.error(err.stack); 25 | }); 26 | -------------------------------------------------------------------------------- /__mocks__/fs.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | 'use strict'; 4 | 5 | const fs = jest.genMockFromModule('fs'); 6 | 7 | let READ_FILE = {}; 8 | 9 | function __setReadFile(readFile) { 10 | READ_FILE = readFile; 11 | } 12 | 13 | function readFileSync() { 14 | return READ_FILE; 15 | } 16 | 17 | const WRITTEN_FILES = {}; 18 | 19 | function __getWrittenFile(name) { 20 | return WRITTEN_FILES[name]; 21 | } 22 | 23 | function writeFileSync(name, file) { 24 | WRITTEN_FILES[name] = file; 25 | } 26 | 27 | fs.readFileSync = readFileSync; 28 | fs.writeFileSync = writeFileSync; 29 | fs.__setReadFile = __setReadFile; 30 | fs.__getWrittenFile = __getWrittenFile; 31 | 32 | module.exports = fs; 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Brian Schlenker 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 | -------------------------------------------------------------------------------- /scripts/setup.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | const fs = require('fs'); 6 | const path = require('path'); 7 | const { 8 | generateConfig, 9 | mergeConfigs, 10 | HOMEBRIDGE_CONFIG, 11 | } = require('../src/setup'); 12 | 13 | const rootDir = path.join(__dirname, '..'); 14 | const configFile = path.join(rootDir, 'config.json'); 15 | const sampleConfig = path.join(__dirname, 'sample-config.json'); 16 | 17 | if (fs.existsSync(configFile)) { 18 | console.log('using existing config.json'); 19 | process.exit(0); 20 | } 21 | 22 | generateConfig() 23 | .then((config) => { 24 | console.log( 25 | 'generating config.json... ensure your Roku device is powered on', 26 | ); 27 | let merged; 28 | if (fs.existsSync(HOMEBRIDGE_CONFIG)) { 29 | merged = mergeConfigs(HOMEBRIDGE_CONFIG, config); 30 | } else { 31 | console.log(`${HOMEBRIDGE_CONFIG} does not exist, using sample config`); 32 | merged = mergeConfigs(sampleConfig, config); 33 | } 34 | fs.writeFileSync(configFile, JSON.stringify(merged, null, 4)); 35 | }) 36 | .catch((err) => { 37 | console.error('failed to configure development config file', err); 38 | process.exit(1); 39 | }); 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "homebridge-roku", 3 | "version": "3.0.2", 4 | "description": "Control Roku media players through homebridge", 5 | "main": "index.js", 6 | "scripts": { 7 | "lint": "eslint bin/* src/**/*.js scripts/**/*.js", 8 | "test": "npm run lint && jest", 9 | "test:watch": "jest --watch", 10 | "start": "./scripts/setup.js && DEBUG=* homebridge -D -U . -P .", 11 | "release": "standard-version", 12 | "prepublishOnly": "npm run test" 13 | }, 14 | "engines": { 15 | "node": ">=10.17.0", 16 | "homebridge": ">=1.0.0" 17 | }, 18 | "bin": { 19 | "homebridge-roku-config": "./bin/setup" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git://github.com/bschlenk/homebridge-roku.git" 24 | }, 25 | "keywords": [ 26 | "roku", 27 | "homebridge", 28 | "homekit", 29 | "homebridge-plugin" 30 | ], 31 | "author": "Brian Schlenker ", 32 | "license": "MIT", 33 | "bugs": { 34 | "url": "https://github.com/bschlenk/homebridge-roku/issues" 35 | }, 36 | "homepage": "https://github.com/bschlenk/homebridge-roku#readme", 37 | "dependencies": { 38 | "deepmerge": "^4.2.2", 39 | "roku-client": "^4.2.0" 40 | }, 41 | "devDependencies": { 42 | "@commitlint/cli": "^11.0.0", 43 | "@commitlint/config-conventional": "^11.0.0", 44 | "@types/jest": "^26.0.14", 45 | "eslint": "^7.10.0", 46 | "eslint-config-airbnb-base": "^14.2.0", 47 | "eslint-config-prettier": "^6.12.0", 48 | "eslint-plugin-import": "^2.22.0", 49 | "eslint-plugin-prettier": "^3.1.4", 50 | "hap-nodejs": "^0.8.2", 51 | "husky": "^4.3.0", 52 | "jest": "^26.4.2", 53 | "prettier": "^2.1.2", 54 | "standard-version": "^9.0.0" 55 | }, 56 | "husky": { 57 | "hooks": { 58 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /__mocks__/roku-client.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | 'use strict'; 4 | 5 | const roku = jest.genMockFromModule('roku-client'); 6 | 7 | class Client { 8 | constructor(ip, apps, info) { 9 | this.ip = ip; 10 | this._activeApp = null; 11 | this._lastLaunched = null; 12 | this._apps = apps; 13 | this._info = info; 14 | this._keys = []; 15 | } 16 | 17 | active() { 18 | return Promise.resolve({ 19 | id: this._activeApp 20 | }); 21 | } 22 | 23 | launch(id) { 24 | this._lastLaunched = id; 25 | return Promise.resolve(); 26 | } 27 | 28 | apps() { 29 | return Promise.resolve(this._apps); 30 | } 31 | 32 | info() { 33 | return Promise.resolve(this._info); 34 | } 35 | 36 | keypress(key) { 37 | this._keys.push(typeof key === 'string' ? key : key.command); 38 | return Promise.resolve(); 39 | } 40 | 41 | command() { 42 | const pushKeys = (key, n) => { 43 | const command = typeof key === 'string' ? key : key.command; 44 | for (let i = 0; i < n; ++i) { 45 | this._keys.push(`${command[0].toUpperCase()}${command.substr(1)}`); 46 | } 47 | }; 48 | const proxy = new Proxy( 49 | { 50 | send: () => Promise.resolve(), 51 | keypress: (key, n) => { 52 | pushKeys(key, n); 53 | return proxy; 54 | }, 55 | exec: cb => { 56 | cb(proxy); 57 | return proxy; 58 | } 59 | }, 60 | { 61 | get: (target, prop) => { 62 | if (prop in target) { 63 | return target[prop]; 64 | } 65 | return (n = 1) => { 66 | pushKeys(prop, n); 67 | return proxy; 68 | }; 69 | } 70 | } 71 | ); 72 | return proxy; 73 | } 74 | } 75 | 76 | let CLIENT = new Client(); 77 | 78 | function __setClient(ip, apps, info) { 79 | CLIENT = new Client(ip, apps, info); 80 | } 81 | 82 | function discover() { 83 | return Promise.resolve(CLIENT.ip); 84 | } 85 | 86 | Client.discover = () => Promise.resolve(CLIENT); 87 | 88 | roku.discover = discover; 89 | roku.__setClient = __setClient; 90 | roku.Client = Client; 91 | 92 | module.exports = roku; 93 | -------------------------------------------------------------------------------- /src/setup.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Client } = require('roku-client'); 4 | const fs = require('fs'); 5 | const os = require('os'); 6 | const path = require('path'); 7 | const deepmerge = require('deepmerge'); 8 | 9 | const HOMEBRIDGE_CONFIG = path.join(os.homedir(), '.homebridge', 'config.json'); 10 | 11 | /** 12 | * Generate or merge the configuration for homebridge-roku by querying 13 | * roku for information and installed apps. 14 | * @return {Promise} 15 | */ 16 | async function generateConfig() { 17 | const client = await Client.discover(); 18 | const [info, apps] = await Promise.all([client.info(), client.apps()]); 19 | const inputs = apps.map((app) => ({ id: app.id, name: app.name })); 20 | return { 21 | accessories: [ 22 | { 23 | name: 'Roku', 24 | accessory: 'Roku', 25 | ip: client.ip, 26 | inputs, 27 | info, 28 | }, 29 | ], 30 | }; 31 | } 32 | 33 | /** 34 | * Pass to `deepmerge` to merge together objects with the same name 35 | * within merging arrays. 36 | * @param {any[]} dest The destination array. 37 | * @param {any[]} source The source array. 38 | * @return {any[]} The new merged array. 39 | */ 40 | function arrayMerge(dest, source) { 41 | const merged = dest.map((destEl) => { 42 | if (!('name' in destEl)) { 43 | return destEl; 44 | } 45 | const idx = source.findIndex((sourceEl) => destEl.name === sourceEl.name); 46 | if (idx >= 0) { 47 | const [match] = source.splice(idx, 1); 48 | return deepmerge(destEl, match); 49 | } 50 | return destEl; 51 | }); 52 | return [...merged, ...source]; 53 | } 54 | 55 | /** 56 | * Merge two config files together. Assumes that 57 | * string arguments are file names and loads them 58 | * before merging. 59 | * @param {Object|string} configAName 60 | * @param {Object|string} configBName 61 | * @return {Object} The merged config. 62 | */ 63 | function mergeConfigs(configAName, configBName) { 64 | function readConfig(name) { 65 | if (typeof name === 'string') { 66 | return JSON.parse(fs.readFileSync(name, 'utf-8')); 67 | } 68 | return name; 69 | } 70 | const configA = readConfig(configAName); 71 | const configB = readConfig(configBName); 72 | return deepmerge(configA, configB, { arrayMerge }); 73 | } 74 | 75 | /** 76 | * Merge the given config object with the existing homebridge config. 77 | * @param {Object} toMerge 78 | */ 79 | function mergeConfigWithMaster(toMerge) { 80 | try { 81 | const merged = mergeConfigs(HOMEBRIDGE_CONFIG, toMerge); 82 | fs.writeFileSync(HOMEBRIDGE_CONFIG, JSON.stringify(merged, null, 4)); 83 | } catch (err) { 84 | console.error(`There was a problem merging the config: ${err}`); 85 | } 86 | } 87 | 88 | module.exports = { 89 | generateConfig, 90 | mergeConfigs, 91 | mergeConfigWithMaster, 92 | HOMEBRIDGE_CONFIG, 93 | }; 94 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [3.0.2](https://github.com/bschlenk/homebridge-roku/compare/v3.0.1...v3.0.2) (2021-03-02) 6 | 7 | ### [3.0.1](https://github.com/bschlenk/homebridge-roku/compare/v3.0.0...v3.0.1) (2020-06-01) 8 | 9 | 10 | ### Docs 11 | 12 | * update npm badge in README ([de02926](https://github.com/bschlenk/homebridge-roku/commit/de029267bfe05750ab787e977fc2c18bb2a22ae8)) 13 | 14 | 15 | ## [3.0.0](https://github.com/bschlenk/homebridge-roku/compare/v3.0.0-alpha.1...v3.0.0) (2020-06-01) 16 | 17 | 18 | ### ⚠ BREAKING CHANGES 19 | 20 | * Now requires a minimum of NodeJS v10.17.0 21 | * Now requires a minimum of homebridge v1.0.0 22 | * appMap config replaced with inputs 23 | 24 | ### Features 25 | 26 | * add infoButtonOverride config ([67ae14b](https://github.com/bschlenk/homebridge-roku/commit/67ae14b7e058d627635f0012d9b457930ea44cfc)) 27 | * change default volume increment to 1 ([b6a1d1f](https://github.com/bschlenk/homebridge-roku/commit/b6a1d1f952bbc74713a51e015006b9b27e09a9ea)) 28 | * merge config in more logical order ([f5d7f87](https://github.com/bschlenk/homebridge-roku/commit/f5d7f8762a944430e9c093fec09c600fbbfbae1b)) 29 | 30 | 31 | ### Bug Fixes 32 | 33 | * eslint errors ([f8029f4](https://github.com/bschlenk/homebridge-roku/commit/f8029f4b244cc9549942f575a25b66ee28454168)) 34 | * throw helpful error if hap-nodejs can't be imported ([10d413a](https://github.com/bschlenk/homebridge-roku/commit/10d413ade2bf5942d9befef0806deacd6aa67904)) 35 | * use injected hap instead of requiring ([5446afb](https://github.com/bschlenk/homebridge-roku/commit/5446afb8971f4cd4a148a1086f53333f692c60d8)) 36 | * uuid error when isTV is false ([c1c67ca](https://github.com/bschlenk/homebridge-roku/commit/c1c67ca833206671387c6d7fdb9536ff846e0c0f)) 37 | 38 | 39 | # [3.0.0-alpha.1](https://github.com/bschlenk/homebridge-roku/compare/v3.0.0-alpha.0...v3.0.0-alpha.1) (2019-02-13) 40 | 41 | 42 | 43 | 44 | # 3.0.0-alpha.0 (2019-02-12) 45 | 46 | 47 | ### Features 48 | 49 | * add space between roku name and switch names ([37bc85a](https://github.com/bschlenk/homebridge-roku/commit/37bc85a)) 50 | * allow configuring volume increment ([f494999](https://github.com/bschlenk/homebridge-roku/commit/f494999)) 51 | * **hap-tv:** make setup script produce new style ordered inputs ([e093866](https://github.com/bschlenk/homebridge-roku/commit/e093866)) 52 | * **hap-tv:** more reliable toggling of power on/off ([c6b0925](https://github.com/bschlenk/homebridge-roku/commit/c6b0925)) 53 | * **hap-tv:** move to iOS 12.1 HAP television and input source services ([bc70333](https://github.com/bschlenk/homebridge-roku/commit/bc70333)) 54 | * **hap-tv:** set Firmware Version for Accessory Settings in Home ([48a547a](https://github.com/bschlenk/homebridge-roku/commit/48a547a)) 55 | 56 | 57 | 58 | 59 | # 2.1.0 (2018-11-26) 60 | 61 | 62 | ### Features 63 | 64 | * add space between roku name and switch names ([37bc85a](https://github.com/bschlenk/homebridge-roku/commit/37bc85a)) 65 | * allow configuring volume increment ([f494999](https://github.com/bschlenk/homebridge-roku/commit/f494999)) 66 | 67 | 68 | 69 | 70 | ## 2.0.1 (2018-11-26) 71 | -------------------------------------------------------------------------------- /src/__tests__/setup.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { 4 | generateConfig, 5 | mergeConfigWithMaster, 6 | HOMEBRIDGE_CONFIG, 7 | } = require('../setup'); 8 | 9 | jest.mock('roku-client'); 10 | jest.mock('fs'); 11 | 12 | const IP = '192.168.1.1'; 13 | 14 | describe('setup', () => { 15 | describe('#generateConfig()', () => { 16 | beforeEach(() => { 17 | // eslint-disable-next-line global-require 18 | require('roku-client').__setClient( 19 | IP, 20 | [ 21 | { name: 'Netflix', id: '1234' }, 22 | { name: 'Spotify', id: '4567' }, 23 | ], 24 | { 25 | manufacturer: 'TCL', 26 | serialNumber: '12345', 27 | }, 28 | ); 29 | }); 30 | 31 | it('should return the generated config', async () => { 32 | const config = await generateConfig(); 33 | expect(config).toEqual({ 34 | accessories: [ 35 | { 36 | accessory: 'Roku', 37 | name: 'Roku', 38 | ip: IP, 39 | inputs: [ 40 | { id: '1234', name: 'Netflix' }, 41 | { id: '4567', name: 'Spotify' }, 42 | ], 43 | info: { manufacturer: 'TCL', serialNumber: '12345' }, 44 | }, 45 | ], 46 | }); 47 | }); 48 | }); 49 | 50 | describe('#mergeConfigWithMaster()', () => { 51 | beforeEach(() => { 52 | // eslint-disable-next-line global-require 53 | require('fs').__setReadFile( 54 | JSON.stringify({ 55 | bridge: { 56 | name: 'homebridge', 57 | }, 58 | description: 'test', 59 | accessories: [ 60 | { 61 | accessory: 'test', 62 | name: 'test', 63 | }, 64 | ], 65 | }), 66 | ); 67 | }); 68 | 69 | it('should combine the existing config with the given config', () => { 70 | mergeConfigWithMaster({ 71 | accessories: [ 72 | { 73 | accessory: 'Roku', 74 | name: 'Roku', 75 | ip: IP, 76 | }, 77 | ], 78 | }); 79 | 80 | const written = JSON.parse( 81 | // eslint-disable-next-line global-require 82 | require('fs').__getWrittenFile(HOMEBRIDGE_CONFIG), 83 | ); 84 | expect(written).toEqual({ 85 | bridge: { 86 | name: 'homebridge', 87 | }, 88 | description: 'test', 89 | accessories: [ 90 | { 91 | accessory: 'test', 92 | name: 'test', 93 | }, 94 | { 95 | accessory: 'Roku', 96 | name: 'Roku', 97 | ip: IP, 98 | }, 99 | ], 100 | }); 101 | }); 102 | 103 | it('should combine two configs if they have the same name', () => { 104 | mergeConfigWithMaster({ 105 | accessories: [ 106 | { 107 | accessory: 'Roku', 108 | name: 'Roku', 109 | ip: IP, 110 | }, 111 | { 112 | name: 'test', 113 | ip: 'abc', 114 | }, 115 | ], 116 | }); 117 | 118 | const written = JSON.parse( 119 | // eslint-disable-next-line global-require 120 | require('fs').__getWrittenFile(HOMEBRIDGE_CONFIG), 121 | ); 122 | expect(written).toEqual({ 123 | bridge: { 124 | name: 'homebridge', 125 | }, 126 | description: 'test', 127 | accessories: [ 128 | { 129 | accessory: 'test', 130 | name: 'test', 131 | ip: 'abc', 132 | }, 133 | { 134 | accessory: 'Roku', 135 | name: 'Roku', 136 | ip: IP, 137 | }, 138 | ], 139 | }); 140 | }); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # homebridge-roku 2 | 3 | [![npm][npm]][npm-url] 4 | 5 | Control your Roku media player from your iOS devices using apple's HomeKit. See 6 | [homebridge](https://github.com/nfarina/homebridge) for more information 7 | controlling 3rd party devices through HomeKit. 8 | 9 | _homebridge-roku requires iOS 12.2 or later_ 10 | 11 | ## Installation 12 | 13 | Make sure that [homebridge is installed][homebridge-install] first, then: 14 | 15 | 1. Install globally by running `npm install -g homebridge-roku@latest` 16 | 2. Ensure Roku device is turned on 17 | 3. Update config file with Roku info by running `homebridge-roku-config --merge` 18 | 19 | ### Additional Installation Info 20 | 21 | A config file must exist at `~/.homebridge/config.json`. See the 22 | [sample config file](https://github.com/nfarina/homebridge/blob/master/config-sample.json) 23 | for an example. 24 | 25 | You can run `homebridge-roku-config` by itself to print out the homebride-roku 26 | config and manually add it to `~/.homebridge/config.json` if you prefer. 27 | 28 | Any time you install a new app, repeat step 3 to update the config file with the 29 | app's info. You can also remove apps from the generated config's inputs section 30 | if you don't want to be able to launch them with Siri. 31 | 32 | See 33 | [homebridge#installing-plugins](https://github.com/homebridge/homebridge#installing-plugins) 34 | for more information. 35 | 36 | ## Available Commands 37 | 38 | The built in iOS remote needs to be enabled to use it: 39 | 40 | `Settings > Control Center > Customize Controls > Apple TV Remote` 41 | 42 | This will allow you to access the remote from Control Center. 43 | 44 | ## Configuration 45 | 46 | The command invocations can be modified by setting the `name` field in the 47 | accessory section of the config. The setup script sets it to `Roku`, but it can 48 | be set to whatever you want it to be. The invocations listed above would then 49 | use the name configured instead of `Roku`. 50 | 51 | ### inputs 52 | 53 | The list of inputs that your TV supports is generated when you run the 54 | `homebridge-roku-config` setup. When you add/remove inputs on your TV, you may 55 | need to re-run `homebridge-roku-config` to get them to show up in homekit. If 56 | you would like to hide certain inputs, such as FandangoNOW or HDMI ARC, you can 57 | remove them from the list of inputs in your config. 58 | 59 | ### volumeIncrement / volumeDecrement 60 | 61 | The amount that volume will be increased or decreased per volume up/down command 62 | can be set in the config. By default, both up and down will be done in 63 | increments of 1. To change this, there are two settings: `volumeIncrement` and 64 | `volumeDecrement`. If only `volumeIncrement` is set, then both volume up and 65 | down will change by the same amount. 66 | 67 | ### infoButtonOverride 68 | 69 | The iOS control center remote isn't that great - it only gives you access to the 70 | arrows, `ok`, `play/pause`, `back`, and `info`. To make it a little more useful, 71 | you can override the functionality of the `info` button to whatever key you 72 | want. For example, to make it behave as the `home` button, add this to your 73 | homebridge config for your Roku accessory: `"infoButtonOverride": "HOME"`. The 74 | list of possible keys can be found 75 | [here](https://github.com/bschlenk/node-roku-client/blob/master/lib/keys.ts). 76 | 77 | ## Migrating Major Versions 78 | 79 | ### 2.x.x -> 3.x.x 80 | 81 | This release focuses on supporting iOS 12.2's new television homekit service. 82 | 83 | The `appMap` field of the config file has been renamed `inputs` and is now an 84 | array of objects. This change is to support the television service, which 85 | requires inputs have stable, sequential ids. Running 86 | `homebridge-roku-config --merge` after upgrading to version 3 should add the new 87 | `inputs` field. You should be able to remove the now unused `appMap` section 88 | from your config. 89 | 90 | This plugin now requires a minimum of 91 | [Homebridge v1.0.0](https://github.com/homebridge/homebridge/releases/tag/1.0.0), 92 | and NodeJS v10.17.0. Please refer to the [homebridge installation 93 | guide][homebridge-install] for instructions on installing a supported version of 94 | NodeJS on your platform. 95 | 96 | ### 1.x.x -> 2.x.x 97 | 98 | Roku info now comes back camelcase, and code expects camelcase. Running 99 | `homebridge-roku-config --merge` now merges accessory configs if they have the 100 | same `name` field, so running this once should be enough to upgrade to `2.x.x`. 101 | 102 | ## Contributing 103 | 104 | There are many versions of Roku devices, each with a different feature set. In 105 | order to support features across all these devices, it would be helpful to see 106 | what config values each one exposes. If you would like to help out, feel free to 107 | add your config to 108 | [this issue](https://github.com/bschlenk/homebridge-roku/issues/9). You can 109 | replace any fields you think are private with "\". 110 | 111 | ## Limitations 112 | 113 | The current volume level can't be queried, so you can't ask for the volume to be 114 | set to a specific value, only relative values can be used. This could be 115 | overcome by sending 100 volume down requests before sending X amount of volume 116 | up requests. I didn't feel like implementing this for obvious reasons, but pull 117 | requests are welcome :) 118 | 119 | ## TODO 120 | 121 | - Possibly fetch apps at homebridge start time or periodically so that the 122 | config generator doesn't need to be run when new channels are installed. 123 | - Document the different Siri invocations 124 | 125 | [npm]: https://img.shields.io/npm/v/homebridge-roku.svg?logo=npm 126 | [npm-url]: https://www.npmjs.com/package/homebridge-roku 127 | [homebridge-install]: https://github.com/homebridge/homebridge#installation 128 | -------------------------------------------------------------------------------- /src/homebridge-roku.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Client, keys } = require('roku-client'); 4 | const plugin = require('../package'); 5 | 6 | let hap; 7 | let Service; 8 | let Characteristic; 9 | 10 | const DEFAULT_VOLUME_INCREMENT = 1; 11 | 12 | const DisplayOrderTypes = { 13 | ARRAY_ELEMENT_START: 0x1, 14 | ARRAY_ELEMENT_END: 0x0, 15 | }; 16 | 17 | class RokuAccessory { 18 | constructor(log, config) { 19 | this.log = log; 20 | this.name = config.name; 21 | 22 | if (!config.ip) { 23 | throw new Error(`An ip address is required for plugin ${this.name}`); 24 | } 25 | 26 | this.info = config.info; 27 | this.inputs = config.inputs; 28 | this.roku = new Client(config.ip); 29 | this.services = []; 30 | 31 | this.volumeIncrement = config.volumeIncrement || DEFAULT_VOLUME_INCREMENT; 32 | this.volumeDecrement = config.volumeDecrement || this.volumeIncrement; 33 | 34 | this.muted = false; 35 | 36 | let infoButton = keys.INFO; 37 | if (config.infoButtonOverride) { 38 | const override = keys[config.infoButtonOverride]; 39 | if (!override) { 40 | throw new Error( 41 | `Invalid value "${ 42 | config.infoButtonOverride 43 | }" for infoButtonOverride, must be one of ${Object.keys(keys).join( 44 | ', ', 45 | )}`, 46 | ); 47 | } 48 | infoButton = override; 49 | } 50 | 51 | this.buttons = { 52 | [Characteristic.RemoteKey.REWIND]: keys.REVERSE, 53 | [Characteristic.RemoteKey.FAST_FORWARD]: keys.FORWARD, 54 | [Characteristic.RemoteKey.NEXT_TRACK]: keys.REVERSE, 55 | [Characteristic.RemoteKey.PREVIOUS_TRACK]: keys.FORWARD, 56 | [Characteristic.RemoteKey.ARROW_UP]: keys.UP, 57 | [Characteristic.RemoteKey.ARROW_DOWN]: keys.DOWN, 58 | [Characteristic.RemoteKey.ARROW_LEFT]: keys.LEFT, 59 | [Characteristic.RemoteKey.ARROW_RIGHT]: keys.RIGHT, 60 | [Characteristic.RemoteKey.SELECT]: keys.SELECT, 61 | [Characteristic.RemoteKey.BACK]: keys.BACK, 62 | [Characteristic.RemoteKey.EXIT]: keys.HOME, 63 | [Characteristic.RemoteKey.PLAY_PAUSE]: keys.PLAY, 64 | [Characteristic.RemoteKey.INFORMATION]: infoButton, 65 | }; 66 | 67 | this.setup(); 68 | } 69 | 70 | setup() { 71 | this.services.push(this.setupAccessoryInfo()); 72 | const television = this.setupTelevision(); 73 | this.services.push(television); 74 | const speaker = this.setupTelevisionSpeaker(); 75 | if (speaker) { 76 | this.services.push(speaker); 77 | } 78 | this.services.push(...this.setupInputs(television)); 79 | } 80 | 81 | setupAccessoryInfo() { 82 | const accessoryInfo = new Service.AccessoryInformation(); 83 | 84 | accessoryInfo 85 | .setCharacteristic( 86 | Characteristic.Manufacturer, 87 | this.info.vendorName || 'Roku, Inc.', 88 | ) 89 | .setCharacteristic( 90 | Characteristic.Name, 91 | this.info.friendlyModelName || 'Roku', 92 | ) 93 | .setCharacteristic(Characteristic.Model, this.info.modelName) 94 | .setCharacteristic(Characteristic.SerialNumber, this.info.serialNumber) 95 | .setCharacteristic( 96 | Characteristic.FirmwareRevision, 97 | this.info.softwareVersion || plugin.version, 98 | ); 99 | 100 | return accessoryInfo; 101 | } 102 | 103 | setupTelevision() { 104 | const television = new Service.Television(this.name); 105 | 106 | television 107 | .getCharacteristic(Characteristic.Active) 108 | .on('get', (callback) => { 109 | this.roku 110 | .info() 111 | .then((info) => { 112 | const value = 113 | info.powerMode === 'PowerOn' 114 | ? Characteristic.Active.ACTIVE 115 | : Characteristic.Active.INACTIVE; 116 | callback(null, value); 117 | }) 118 | .catch(callback); 119 | }) 120 | .on('set', (newValue, callback) => { 121 | if (newValue === Characteristic.Active.ACTIVE) { 122 | this.roku 123 | .keypress('PowerOn') 124 | .then(() => callback(null)) 125 | .catch(callback); 126 | } else { 127 | this.roku 128 | .keypress('PowerOff') 129 | .then(() => callback(null)) 130 | .catch(callback); 131 | } 132 | }); 133 | 134 | television 135 | .getCharacteristic(Characteristic.ActiveIdentifier) 136 | .on('get', (callback) => { 137 | this.roku 138 | .active() 139 | .then((app) => { 140 | const index = 141 | app !== null 142 | ? this.inputs.findIndex((input) => input.id === app.id) 143 | : -1; 144 | const hapId = index + 1; 145 | callback(null, hapId); 146 | }) 147 | .catch(callback); 148 | }) 149 | .on('set', (index, callback) => { 150 | const rokuId = this.inputs[index - 1].id; 151 | this.roku 152 | .launch(rokuId) 153 | .then(() => callback(null)) 154 | .catch(callback); 155 | }); 156 | 157 | television 158 | .getCharacteristic(Characteristic.ConfiguredName) 159 | .setValue(this.info.userDeviceName) 160 | .setProps({ 161 | perms: [Characteristic.Perms.READ], 162 | }); 163 | 164 | television.setCharacteristic( 165 | Characteristic.SleepDiscoveryMode, 166 | Characteristic.SleepDiscoveryMode.ALWAYS_DISCOVERABLE, 167 | ); 168 | 169 | television.getCharacteristic(Characteristic.DisplayOrder).setProps({ 170 | perms: [Characteristic.Perms.READ], 171 | }); 172 | 173 | television 174 | .getCharacteristic(Characteristic.RemoteKey) 175 | .on('set', (newValue, callback) => { 176 | this.roku 177 | .keypress(this.buttons[newValue]) 178 | .then(() => callback(null)) 179 | .catch(callback); 180 | }); 181 | 182 | return television; 183 | } 184 | 185 | setupTelevisionSpeaker() { 186 | if (this.info.isTv !== 'true') { 187 | return null; 188 | } 189 | 190 | const speaker = new Service.TelevisionSpeaker(`${this.name} Speaker`); 191 | 192 | speaker.setCharacteristic( 193 | Characteristic.VolumeControlType, 194 | Characteristic.VolumeControlType.RELATIVE, 195 | ); 196 | 197 | speaker 198 | .getCharacteristic(Characteristic.Mute) 199 | .on('get', (callback) => callback(null, this.muted)) 200 | .on('set', (value, callback) => { 201 | this.muted = value; 202 | this.roku 203 | .command() 204 | // toggling the volume up and down is a reliable way to unmute 205 | // the TV if the current state is not known 206 | .volumeDown() 207 | .volumeUp() 208 | .exec((cmd) => this.muted && cmd.volumeMute()) 209 | .send() 210 | .then(() => callback(null)) 211 | .catch(callback); 212 | }); 213 | 214 | speaker 215 | .getCharacteristic(Characteristic.VolumeSelector) 216 | .on('set', (newValue, callback) => { 217 | if (newValue === Characteristic.VolumeSelector.INCREMENT) { 218 | this.roku 219 | .command() 220 | .keypress(keys.VOLUME_UP, this.volumeIncrement) 221 | .send() 222 | .then(() => callback(null)) 223 | .catch(callback); 224 | } else { 225 | this.roku 226 | .command() 227 | .keypress(keys.VOLUME_DOWN, this.volumeDecrement) 228 | .send() 229 | .then(() => callback(null)) 230 | .catch(callback); 231 | } 232 | }); 233 | 234 | return speaker; 235 | } 236 | 237 | setupInputs(television) { 238 | let identifiersTLV = Buffer.alloc(0); 239 | const inputs = this.inputs.map((config, index) => { 240 | const hapId = index + 1; 241 | const input = this.setupInput(config.id, config.name, hapId, television); 242 | 243 | if (identifiersTLV.length !== 0) { 244 | identifiersTLV = Buffer.concat([ 245 | identifiersTLV, 246 | hap.encode(DisplayOrderTypes.ARRAY_ELEMENT_END, Buffer.alloc(0)), 247 | ]); 248 | } 249 | 250 | const element = hap.writeUInt32(hapId); 251 | identifiersTLV = Buffer.concat([ 252 | identifiersTLV, 253 | hap.encode(DisplayOrderTypes.ARRAY_ELEMENT_START, element), 254 | ]); 255 | 256 | return input; 257 | }); 258 | 259 | television.setCharacteristic( 260 | Characteristic.DisplayOrder, 261 | identifiersTLV.toString('base64'), 262 | ); 263 | 264 | return inputs; 265 | } 266 | 267 | setupInput(rokuId, name, hapId, television) { 268 | const input = new Service.InputSource(`${this.name} ${name}`, rokuId); 269 | const hdmiRegexp = /tvinput\.hdmi\d+/m; 270 | const inputSourceType = hdmiRegexp.test(rokuId) 271 | ? Characteristic.InputSourceType.HDMI 272 | : Characteristic.InputSourceType.APPLICATION; 273 | 274 | input 275 | .setCharacteristic(Characteristic.Identifier, hapId) 276 | .setCharacteristic(Characteristic.ConfiguredName, name) 277 | .setCharacteristic( 278 | Characteristic.IsConfigured, 279 | Characteristic.IsConfigured.CONFIGURED, 280 | ) 281 | .setCharacteristic(Characteristic.InputSourceType, inputSourceType); 282 | 283 | input.getCharacteristic(Characteristic.ConfiguredName).setProps({ 284 | perms: [Characteristic.Perms.READ], 285 | }); 286 | 287 | television.addLinkedService(input); 288 | return input; 289 | } 290 | 291 | getServices() { 292 | return this.services; 293 | } 294 | } 295 | 296 | module.exports = (homebridge) => { 297 | hap = homebridge.hap; 298 | ({ Service, Characteristic } = homebridge.hap); 299 | 300 | homebridge.registerAccessory('homebridge-roku', 'Roku', RokuAccessory); 301 | }; 302 | -------------------------------------------------------------------------------- /src/__tests__/homebridge-roku.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const hap = require('hap-nodejs'); 4 | const { keys } = require('roku-client'); 5 | const setupService = require('../homebridge-roku'); 6 | 7 | describe('homebridge-roku', () => { 8 | let Accessory; 9 | let accessory; 10 | let config; 11 | const log = () => {}; 12 | const ip = '192.168.1.25'; 13 | const inputs = [ 14 | { id: 1, name: 'Netflix' }, 15 | { id: 2, name: 'Amazon' }, 16 | ]; 17 | 18 | class Characteristic { 19 | constructor() { 20 | this._events = {}; 21 | this._value = undefined; 22 | this._props = undefined; 23 | } 24 | 25 | on(key, value) { 26 | this._events[key] = value; 27 | return this; 28 | } 29 | 30 | setValue(value) { 31 | this._value = value; 32 | return this; 33 | } 34 | 35 | setProps(props) { 36 | this._props = props; 37 | return this; 38 | } 39 | } 40 | 41 | class Service { 42 | constructor(service, name) { 43 | this.service = service; 44 | this.name = name; 45 | this._characteristics = new Map(); 46 | this._linkedServices = []; 47 | } 48 | 49 | setCharacteristic(key, value) { 50 | this.getCharacteristic(key).setValue(value); 51 | return this; 52 | } 53 | 54 | getCharacteristic(key) { 55 | let characteristic = this._characteristics.get(key); 56 | if (!characteristic) { 57 | characteristic = new Characteristic(); 58 | this._characteristics.set(key, characteristic); 59 | } 60 | return characteristic; 61 | } 62 | 63 | addLinkedService(service) { 64 | this._linkedServices.push(service); 65 | return this; 66 | } 67 | } 68 | 69 | const homebridge = { 70 | registerAccessory(name, service, acc) { 71 | Accessory = acc; 72 | }, 73 | 74 | hap: { 75 | Service: { 76 | AccessoryInformation: Service, 77 | Switch: Service, 78 | Television: Service, 79 | InputSource: Service, 80 | }, 81 | 82 | Characteristic: hap.Characteristic, 83 | 84 | encode: hap.encode, 85 | writeUInt32: hap.writeUInt32, 86 | }, 87 | }; 88 | 89 | beforeEach(() => { 90 | setupService(homebridge); 91 | config = { 92 | name: 'Roku', 93 | ip, 94 | info: { 95 | vendorName: 'abc', 96 | modelName: 'def', 97 | userDeviceName: 'ghi', 98 | serialNumber: 'jkl', 99 | friendlyModelName: 'mno', 100 | }, 101 | inputs, 102 | }; 103 | accessory = new Accessory(log, config); 104 | }); 105 | 106 | it('should fail if no ip address is in config', () => { 107 | expect(() => { 108 | new Accessory(log, { name: 'acc' }); 109 | }).toThrow('An ip address is required for plugin acc'); 110 | }); 111 | 112 | it('should fail if infoButtonOverride is invalid', () => { 113 | expect(() => { 114 | new Accessory(log, { 115 | name: 'acc', 116 | ip, 117 | infoButtonOverride: 'butts', 118 | }); 119 | }).toThrow(/^Invalid value "butts" for infoButtonOverride/); 120 | }); 121 | 122 | it('should allow overriding the info button', () => { 123 | const acc = new Accessory(log, { 124 | name: 'acc', 125 | ip, 126 | info: {}, 127 | inputs: [], 128 | infoButtonOverride: 'HOME', 129 | }); 130 | expect(acc.buttons[hap.Characteristic.RemoteKey.INFORMATION]).toEqual( 131 | keys.HOME, 132 | ); 133 | }); 134 | 135 | it('should set up accessory info', () => { 136 | const accessoryInfo = accessory.services[0]; 137 | const getCh = (ch) => accessoryInfo.getCharacteristic(ch)._value; 138 | expect(getCh(hap.Characteristic.Manufacturer)).toEqual('abc'); 139 | expect(getCh(hap.Characteristic.Model)).toEqual('def'); 140 | expect(getCh(hap.Characteristic.Name)).toEqual('mno'); 141 | expect(getCh(hap.Characteristic.SerialNumber)).toEqual('jkl'); 142 | }); 143 | 144 | it('should return the available services', () => { 145 | const services = accessory.getServices(); 146 | expect(services.length).toEqual(4); 147 | }); 148 | 149 | describe.skip('PowerSwitch', () => { 150 | let powerSwitch; 151 | let on; 152 | 153 | beforeEach(() => { 154 | // eslint-disable-next-line 155 | powerSwitch = accessory.services[1]; 156 | on = powerSwitch.getCharacteristic('on'); 157 | }); 158 | 159 | it('should have proper service and name set', () => { 160 | expect(powerSwitch.service).toEqual('Roku Power'); 161 | expect(powerSwitch.name).toEqual('Power'); 162 | }); 163 | 164 | it('should return the power state', () => { 165 | const getMock = jest.fn(); 166 | on._events.get(getMock); 167 | expect(getMock).toHaveBeenCalledWith(null, false); 168 | 169 | getMock.mockClear(); 170 | accessory.poweredOn = true; 171 | on._events.get(getMock); 172 | expect(getMock).toHaveBeenCalledWith(null, true); 173 | }); 174 | 175 | it('should set the power state & send power keypress to roku', (done) => { 176 | on._events.set(true, (val) => { 177 | expect(val).toBeNull(); 178 | expect(accessory.poweredOn).toBeTruthy(); 179 | expect(accessory.roku._keys).toEqual(['Power']); 180 | done(); 181 | }); 182 | }); 183 | }); 184 | 185 | describe.skip('MuteSwitch', () => { 186 | let muteSwitch; 187 | let on; 188 | 189 | beforeEach(() => { 190 | // eslint-disable-next-line 191 | muteSwitch = accessory.services[2]; 192 | on = muteSwitch.getCharacteristic('on'); 193 | }); 194 | 195 | it('should return the mute state', () => { 196 | const getMock = jest.fn(); 197 | on._events.get(getMock); 198 | expect(getMock).toHaveBeenCalledWith(null, false); 199 | 200 | getMock.mockClear(); 201 | accessory.muted = true; 202 | on._events.get(getMock); 203 | expect(getMock).toHaveBeenCalledWith(null, true); 204 | }); 205 | 206 | it('should call mute if mute is true', (done) => { 207 | on._events.set(true, (val) => { 208 | expect(val).toBeNull(); 209 | expect(accessory.muted).toBeTruthy(); 210 | expect(accessory.roku._keys).toEqual([ 211 | 'VolumeDown', 212 | 'VolumeUp', 213 | 'VolumeMute', 214 | ]); 215 | done(); 216 | }); 217 | }); 218 | 219 | it('should not call mute if mute is false', (done) => { 220 | on._events.set(false, (val) => { 221 | expect(val).toBeNull(); 222 | expect(accessory.muted).toBeFalsy(); 223 | expect(accessory.roku._keys).toEqual(['VolumeDown', 'VolumeUp']); 224 | done(); 225 | }); 226 | }); 227 | }); 228 | 229 | ['VolumeUp', 'VolumeDown'].forEach((keypress, i) => { 230 | describe.skip(keypress, () => { 231 | let keySwitch; 232 | let on; 233 | 234 | beforeEach(() => { 235 | // eslint-disable-next-line 236 | keySwitch = accessory.services[3 + i]; 237 | on = keySwitch.getCharacteristic('on'); 238 | }); 239 | 240 | it('should have the proper service and name', () => { 241 | expect(keySwitch.service).toEqual(`Roku ${keypress}`); 242 | expect(keySwitch.name).toEqual(keypress); 243 | }); 244 | 245 | it('should always return false for get', () => { 246 | const getMock = jest.fn(); 247 | on._events.get(getMock); 248 | expect(getMock).toHaveBeenCalledWith(null, false); 249 | }); 250 | 251 | it(`should call ${keypress} 5 times on set`, (done) => { 252 | on._events.set(true, (val1, val2) => { 253 | expect(val1).toBeNull(); 254 | expect(val2).toBeFalsy(); 255 | expect(accessory.roku._keys).toEqual([ 256 | keypress, 257 | keypress, 258 | keypress, 259 | keypress, 260 | keypress, 261 | ]); 262 | done(); 263 | }); 264 | }); 265 | }); 266 | }); 267 | 268 | describe.skip('volumeIncrement setting', () => { 269 | it('should call VolumeUp/Down based on the volumeIncrement setting', (done) => { 270 | config.volumeIncrement = 2; 271 | accessory = new Accessory(() => {}, config); 272 | const upSwitch = accessory.services[3]; 273 | const downSwitch = accessory.services[4]; 274 | const upOn = upSwitch.getCharacteristic('on'); 275 | const downOn = downSwitch.getCharacteristic('on'); 276 | 277 | upOn._events.set(true, () => { 278 | downOn._events.set(true, () => { 279 | expect(accessory.roku._keys).toEqual([ 280 | 'VolumeUp', 281 | 'VolumeUp', 282 | 'VolumeDown', 283 | 'VolumeDown', 284 | ]); 285 | done(); 286 | }); 287 | }); 288 | }); 289 | 290 | it('should allow setting VolumeUp/Down independently', (done) => { 291 | config.volumeIncrement = 4; 292 | config.volumeDecrement = 3; 293 | accessory = new Accessory(() => {}, config); 294 | const upSwitch = accessory.services[3]; 295 | const downSwitch = accessory.services[4]; 296 | const upOn = upSwitch.getCharacteristic('on'); 297 | const downOn = downSwitch.getCharacteristic('on'); 298 | 299 | upOn._events.set(true, () => { 300 | downOn._events.set(true, () => { 301 | expect(accessory.roku._keys).toEqual([ 302 | 'VolumeUp', 303 | 'VolumeUp', 304 | 'VolumeUp', 305 | 'VolumeUp', 306 | 'VolumeDown', 307 | 'VolumeDown', 308 | 'VolumeDown', 309 | ]); 310 | done(); 311 | }); 312 | }); 313 | }); 314 | }); 315 | 316 | inputs.forEach((input, i) => { 317 | describe.skip(`Channel ${input.name}`, () => { 318 | const channelId = input.id; 319 | let channelSwitch; 320 | let on; 321 | 322 | beforeEach(() => { 323 | // eslint-disable-next-line 324 | channelSwitch = accessory.services[5 + i]; 325 | on = channelSwitch.getCharacteristic('on'); 326 | }); 327 | 328 | it('should have proper service and name', () => { 329 | expect(channelSwitch.service).toEqual(`Roku ${input.name}`); 330 | expect(channelSwitch.name).toEqual(`${input.name}`); 331 | }); 332 | 333 | it('should return false if there is no active app', (done) => { 334 | on._events.get((val1, val2) => { 335 | expect(val1).toBeNull(); 336 | expect(val2).toBeFalsy(); 337 | done(); 338 | }); 339 | }); 340 | 341 | it('should return false if the app is not active', (done) => { 342 | accessory.roku._activeApp = channelId + 1; 343 | on._events.get((val1, val2) => { 344 | expect(val1).toBeNull(); 345 | expect(val2).toBeFalsy(); 346 | done(); 347 | }); 348 | }); 349 | 350 | it('should return true if the app active', (done) => { 351 | accessory.roku._activeApp = channelId; 352 | on._events.get((val1, val2) => { 353 | expect(val1).toBeNull(); 354 | expect(val2).toBeTruthy(); 355 | done(); 356 | }); 357 | }); 358 | 359 | it('should launch the given channel', (done) => { 360 | accessory.roku._activeApp = channelId; 361 | on._events.set(true, (val1, val2) => { 362 | expect(accessory.roku._lastLaunched).toEqual(channelId); 363 | expect(val1).toBeNull(); 364 | expect(val2).toBeTruthy(); 365 | done(); 366 | }); 367 | }); 368 | 369 | it('should go home if toggling off', (done) => { 370 | on._events.set(false, (val1, val2) => { 371 | expect(accessory.roku._keys).toEqual(['Home']); 372 | expect(val1).toBeNull(); 373 | expect(val2).toBeFalsy(); 374 | done(); 375 | }); 376 | }); 377 | }); 378 | }); 379 | }); 380 | --------------------------------------------------------------------------------