├── .editorconfig
├── .eslintrc
├── .github
├── ISSUE_TEMPLATE
│ ├── bug-report.md
│ ├── config.yml
│ ├── feature-request.md
│ └── support-request.md
└── workflows
│ └── build.yml
├── .gitignore
├── .npmignore
├── .prettierrc
├── LICENSE
├── README.md
├── config.schema.json
├── nodemon.json
├── package-lock.json
├── package.json
├── src
├── accessory.ts
├── index.ts
├── platform.ts
├── pureDirectAccessory.ts
├── settings.ts
├── storageService.ts
├── types.ts
├── utils
│ └── getZoneStatus.ts
└── volumeAccessory.ts
└── tsconfig.json
/.editorconfig:
--------------------------------------------------------------------------------
1 | # editorconfig.org
2 | root = true
3 |
4 | [*]
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | charset = utf-8
9 | trim_trailing_whitespace = true
10 | insert_final_newline = true
11 |
12 | [*.md]
13 | trim_trailing_whitespace = false
14 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "env": {
4 | "browser": true,
5 | "node": true
6 | },
7 | "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended"],
8 | "plugins": ["@typescript-eslint"],
9 | "rules": {
10 | "semi": ["error", "always"],
11 | "comma-dangle": ["error", "always-multiline"],
12 | "arrow-parens": "off",
13 | "object-curly-newline": [
14 | "error",
15 | {
16 | "ObjectExpression": { "consistent": true },
17 | "ObjectPattern": { "consistent": true },
18 | "ImportDeclaration": { "consistent": true },
19 | "ExportDeclaration": { "consistent": true }
20 | }
21 | ],
22 | "space-before-function-paren": [
23 | "error",
24 | {
25 | "anonymous": "always",
26 | "named": "never",
27 | "asyncArrow": "always"
28 | }
29 | ],
30 | "no-multiple-empty-lines": [
31 | "error",
32 | {
33 | "max": 1,
34 | "maxEOF": 0,
35 | "maxBOF": 0
36 | }
37 | ],
38 | "lines-between-class-members": "off",
39 | "indent": "off",
40 | "@typescript-eslint/indent": "off",
41 | "@typescript-eslint/no-explicit-any": "off",
42 | "@typescript-eslint/no-inferrable-types": "off",
43 | "@typescript-eslint/no-non-null-assertion": "off",
44 | "no-useless-constructor": "off",
45 | "@typescript-eslint/no-useless-constructor": "off",
46 | "@typescript-eslint/no-parameter-properties": "off",
47 | "no-use-before-define": "off",
48 | "no-unused-vars": "off"
49 | },
50 | "overrides": [
51 | {
52 | "files": ["*.js"],
53 | "rules": {
54 | "@typescript-eslint/no-var-requires": "off"
55 | }
56 | },
57 | {
58 | "files": ["*.ts"],
59 | "rules": {
60 | // allow TypeScript method signature overloading, see https://github.com/typescript-eslint/typescript-eslint/issues/291
61 | "no-dupe-class-members": "off",
62 | // disable no-undef rule for TypeScript, see https://github.com/typescript-eslint/typescript-eslint/issues/342
63 | "no-undef": "off"
64 | }
65 | },
66 | {
67 | "files": ["*.vue"],
68 | "rules": {
69 | "vue/html-self-closing": [
70 | "error",
71 | {
72 | "html": {
73 | "void": "always",
74 | "normal": "any",
75 | "component": "always"
76 | },
77 | "svg": "always",
78 | "math": "always"
79 | }
80 | ],
81 | // allow TypeScript method signature overloading, see https://github.com/typescript-eslint/typescript-eslint/issues/291
82 | "no-dupe-class-members": "off",
83 | // disable no-undef rule for TypeScript, see https://github.com/typescript-eslint/typescript-eslint/issues/342
84 | "no-undef": "off"
85 | }
86 | }
87 | ]
88 | }
89 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug-report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug Report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: bug
6 | assignees: ''
7 |
8 | ---
9 |
10 |
11 |
12 | **Describe The Bug:**
13 |
14 |
15 | **To Reproduce:**
16 |
17 |
18 | **Expected behavior:**
19 |
20 |
21 | **Logs:**
22 |
23 | ```
24 | Show the Homebridge logs here, remove any sensitive information.
25 | ```
26 |
27 | **Plugin Config:**
28 |
29 | ```json
30 | Show your Homebridge config.json here, remove any sensitive information.
31 | ```
32 |
33 | **Screenshots:**
34 |
35 |
36 | **Environment:**
37 |
38 | * **Plugin Version**:
39 | * **Homebridge Version**:
40 | * **Node.js Version**:
41 | * **NPM Version**:
42 | * **Operating System**:
43 |
44 |
45 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | # blank_issues_enabled: false
2 | # contact_links:
3 | # - name: Homebridge Discord Community
4 | # url: https://discord.gg/kqNCe2D
5 | # about: Ask your questions in the #YOUR_CHANNEL_HERE channel
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature-request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature Request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: enhancement
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe:**
11 |
12 |
13 | **Describe the solution you'd like:**
14 |
15 |
16 | **Describe alternatives you've considered:**
17 |
18 |
19 | **Additional context:**
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/support-request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Support Request
3 | about: Need help?
4 | title: ''
5 | labels: question
6 | assignees: ''
7 |
8 | ---
9 |
10 |
11 |
12 | **Describe Your Problem:**
13 |
14 |
15 | **Logs:**
16 |
17 | ```
18 | Show the Homebridge logs here, remove any sensitive information.
19 | ```
20 |
21 | **Plugin Config:**
22 |
23 | ```json
24 | Show your Homebridge config.json here, remove any sensitive information.
25 | ```
26 |
27 | **Screenshots:**
28 |
29 |
30 | **Environment:**
31 |
32 | * **Plugin Version**:
33 | * **Homebridge Version**:
34 | * **Node.js Version**:
35 | * **NPM Version**:
36 | * **Operating System**:
37 |
38 |
39 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build and Lint
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 |
9 | strategy:
10 | matrix:
11 | # the Node.js versions to build on
12 | node-version: [10.x, 12.x, 13.x, 14.x]
13 |
14 | steps:
15 | - uses: actions/checkout@v2
16 |
17 | - name: Use Node.js ${{ matrix.node-version }}
18 | uses: actions/setup-node@v1
19 | with:
20 | node-version: ${{ matrix.node-version }}
21 |
22 | - name: Install dependencies
23 | run: npm install
24 |
25 | - name: Lint the project
26 | run: npm run lint
27 |
28 | - name: Build the project
29 | run: npm run build
30 | env:
31 | CI: true
32 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Ignore compiled code
2 | dist
3 |
4 | # ------------- Defaults ------------- #
5 |
6 | # Logs
7 | logs
8 | *.log
9 | npm-debug.log*
10 | yarn-debug.log*
11 | yarn-error.log*
12 | lerna-debug.log*
13 |
14 | # Diagnostic reports (https://nodejs.org/api/report.html)
15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
16 |
17 | # Runtime data
18 | pids
19 | *.pid
20 | *.seed
21 | *.pid.lock
22 |
23 | # Directory for instrumented libs generated by jscoverage/JSCover
24 | lib-cov
25 |
26 | # Coverage directory used by tools like istanbul
27 | coverage
28 | *.lcov
29 |
30 | # nyc test coverage
31 | .nyc_output
32 |
33 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
34 | .grunt
35 |
36 | # Bower dependency directory (https://bower.io/)
37 | bower_components
38 |
39 | # node-waf configuration
40 | .lock-wscript
41 |
42 | # Compiled binary addons (https://nodejs.org/api/addons.html)
43 | build/Release
44 |
45 | # Dependency directories
46 | node_modules/
47 | jspm_packages/
48 |
49 | # Snowpack dependency directory (https://snowpack.dev/)
50 | web_modules/
51 |
52 | # TypeScript cache
53 | *.tsbuildinfo
54 |
55 | # Optional npm cache directory
56 | .npm
57 |
58 | # Optional eslint cache
59 | .eslintcache
60 |
61 | # node-persist cache
62 | .node-persist
63 |
64 | # Microbundle cache
65 | .rpt2_cache/
66 | .rts2_cache_cjs/
67 | .rts2_cache_es/
68 | .rts2_cache_umd/
69 |
70 | # Optional REPL history
71 | .node_repl_history
72 |
73 | # Output of 'npm pack'
74 | *.tgz
75 |
76 | # Yarn Integrity file
77 | .yarn-integrity
78 |
79 | # dotenv environment variables file
80 | .env
81 | .env.test
82 |
83 | # parcel-bundler cache (https://parceljs.org/)
84 | .cache
85 | .parcel-cache
86 |
87 | # Next.js build output
88 | .next
89 |
90 | # Nuxt.js build / generate output
91 | .nuxt
92 | dist
93 |
94 | # Gatsby files
95 | .cache/
96 | # Comment in the public line in if your project uses Gatsby and not Next.js
97 | # https://nextjs.org/blog/next-9-1#public-directory-support
98 | # public
99 |
100 | # vuepress build output
101 | .vuepress/dist
102 |
103 | # Serverless directories
104 | .serverless/
105 |
106 | # FuseBox cache
107 | .fusebox/
108 |
109 | # DynamoDB Local files
110 | .dynamodb/
111 |
112 | # TernJS port file
113 | .tern-port
114 |
115 | # Stores VSCode versions used for testing VSCode extensions
116 | .vscode-test
117 | .vscode
118 |
119 | # yarn v2
120 |
121 | .yarn/cache
122 | .yarn/unplugged
123 | .yarn/build-state.yml
124 | .pnp.*
125 | /.idea
126 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | # Ignore source code
2 | src
3 |
4 | # ------------- Defaults ------------- #
5 |
6 | # gitHub actions
7 | .github
8 |
9 | # eslint
10 | .eslintrc
11 |
12 | # typescript
13 | tsconfig.json
14 |
15 | # vscode
16 | .vscode
17 |
18 | # nodemon
19 | nodemon.json
20 |
21 | # Logs
22 | logs
23 | *.log
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 | lerna-debug.log*
28 |
29 | # Diagnostic reports (https://nodejs.org/api/report.html)
30 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
31 |
32 | # Runtime data
33 | pids
34 | *.pid
35 | *.seed
36 | *.pid.lock
37 |
38 | # Directory for instrumented libs generated by jscoverage/JSCover
39 | lib-cov
40 |
41 | # Coverage directory used by tools like istanbul
42 | coverage
43 | *.lcov
44 |
45 | # nyc test coverage
46 | .nyc_output
47 |
48 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
49 | .grunt
50 |
51 | # Bower dependency directory (https://bower.io/)
52 | bower_components
53 |
54 | # node-waf configuration
55 | .lock-wscript
56 |
57 | # Compiled binary addons (https://nodejs.org/api/addons.html)
58 | build/Release
59 |
60 | # Dependency directories
61 | node_modules/
62 | jspm_packages/
63 |
64 | # Snowpack dependency directory (https://snowpack.dev/)
65 | web_modules/
66 |
67 | # TypeScript cache
68 | *.tsbuildinfo
69 |
70 | # Optional npm cache directory
71 | .npm
72 |
73 | # Optional eslint cache
74 | .eslintcache
75 |
76 | # Microbundle cache
77 | .rpt2_cache/
78 | .rts2_cache_cjs/
79 | .rts2_cache_es/
80 | .rts2_cache_umd/
81 |
82 | # Optional REPL history
83 | .node_repl_history
84 |
85 | # Output of 'npm pack'
86 | *.tgz
87 |
88 | # Yarn Integrity file
89 | .yarn-integrity
90 |
91 | # dotenv environment variables file
92 | .env
93 | .env.test
94 |
95 | # parcel-bundler cache (https://parceljs.org/)
96 | .cache
97 | .parcel-cache
98 |
99 | # Next.js build output
100 | .next
101 |
102 | # Nuxt.js build / generate output
103 | .nuxt
104 | dist
105 |
106 | # Gatsby files
107 | .cache/
108 | # Comment in the public line in if your project uses Gatsby and not Next.js
109 | # https://nextjs.org/blog/next-9-1#public-directory-support
110 | # public
111 |
112 | # vuepress build output
113 | .vuepress/dist
114 |
115 | # Serverless directories
116 | .serverless/
117 |
118 | # FuseBox cache
119 | .fusebox/
120 |
121 | # DynamoDB Local files
122 | .dynamodb/
123 |
124 | # TernJS port file
125 | .tern-port
126 |
127 | # Stores VSCode versions used for testing VSCode extensions
128 | .vscode-test
129 |
130 | # yarn v2
131 |
132 | .yarn/cache
133 | .yarn/unplugged
134 | .yarn/build-state.yml
135 | .pnp.*
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": true,
3 | "singleQuote": true,
4 | "trailingComma": "all",
5 | "arrowParens": "always",
6 | "printWidth": 120
7 | }
8 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | # homebridge-yamaha-avr
14 |
15 | `homebridge-yamaha-avr` is a Homebridge plugin allowing you to control your AVR & any connected HDMI-CEC controllable devices with the Apple Home app & Control Centre remote! It should work with all network accessible receivers.
16 |
17 | The Yamaha AVR will display as an Audio Receiver with Power, Input, Volume & Remote Control.
18 |
19 | ## Requirements
20 |
21 | - iOS 14 (or later)
22 | - [Homebridge](https://homebridge.io/) v1.1.6 (or later)
23 |
24 | ## Installation
25 |
26 | Install homebridge-yamaha-avr:
27 |
28 | ```sh
29 | npm install -g homebridge-yamaha-avr
30 | ```
31 |
32 | ## Usage Notes
33 |
34 | - Quickly switch input using the information (i) button in the Control Centre remote
35 | - Adjust the volume using the physical volume buttons on your iOS device whilst the Control Centre remote is open
36 | - Enable additional zones
37 | - Enable Fan devices to control the volume of each enabled zone
38 | - Enable Switch device to enable/disable Pure Direct
39 |
40 | ## Configuration
41 |
42 | Add a new platform to your homebridge `config.json`.
43 |
44 | Specific "favourite" inputs can be added manually or all available inputs reported by the AVR will be set.
45 |
46 | Example configuration:
47 |
48 | ```js
49 | {
50 | "platforms": [
51 | {
52 | "platform": "yamaha-avr",
53 | "name": "Yamaha RX-V685",
54 | "ip": "192.168.1.12",
55 | "cacheDirectory": "",
56 | "enablePureDirectSwitch": true,
57 | "volumeAccessoryEnabled": true,
58 | "zone2Enabled": true,
59 | "zone3Enabled": false,
60 | "zone4Enabled": false,
61 | }
62 | ]
63 | }
64 | ```
65 |
66 | #### Important Installation/Configuration Notes:
67 |
68 | homebridge-yamaha-avr caches input/name data, by default this should be located within the `homebridge-yamaha-avr` plugin directory. If your homebridge instance cannot write to this directory you can define an alternative cache data location using the `cacheDirectory` config option
69 |
70 | The AVR is published as an external accessory so you'll need to add it manually.
71 |
72 | Select "Add Accessory" in the Home app, then "I Don't Have a Code or Cannot Scan".
73 |
74 | The AVR should then show as an option, enter your Homebridge PIN and you should be good to go.
75 |
76 |
77 |
78 | You can define the ports external accessories are assigned by setting a range in your Homebridge config:
79 | https://github.com/homebridge/homebridge/blob/master/config-sample.json#L12
80 |
81 | ## Other Yamaha Receiver Plugins
82 |
83 | #### [homebridge-yamaha-zone-tv](https://github.com/NorthernMan54/homebridge-yamaha-zone-tv)
84 |
85 | For multi-zone Yamaha Receivers, and uses the Television control for each zone of the receiver.
86 |
87 | #### [homebridge-yamaha-home](https://github.com/NorthernMan54/homebridge-yamaha-home)
88 |
89 | For multi-zone Yamaha Receivers, and uses a Fan to control each zone of the receiver.
90 |
91 | # Contributing
92 |
93 | ## Build Plugin
94 |
95 | TypeScript needs to be compiled into JavaScript before it can run. The following command will compile the contents of the [`src`](./src) directory and put the resulting code into the `dist` folder.
96 |
97 | ```
98 | npm run build
99 | ```
100 |
101 | ## Link To Homebridge
102 |
103 | Run this command so your global install of Homebridge can discover the plugin in your development environment:
104 |
105 | ```
106 | npm link
107 | ```
108 |
109 | You can now start Homebridge, use the `-D` flag so you can see debug log messages:
110 |
111 | ```
112 | homebridge -D
113 | ```
114 |
115 | ## Watch For Changes and Build Automatically
116 |
117 | If you want to have your code compile automatically as you make changes, and restart Homebridge automatically between changes you can run:
118 |
119 | ```
120 | npm run watch
121 | ```
122 |
123 | This will launch an instance of Homebridge in debug mode which will restart every time you make a change to the source code. It will load the config stored in the default location under `~/.homebridge`. You may need to stop other running instances of Homebridge while using this command to prevent conflicts. You can adjust the Homebridge startup command in the [`nodemon.json`](./nodemon.json) file.
124 |
--------------------------------------------------------------------------------
/config.schema.json:
--------------------------------------------------------------------------------
1 | {
2 | "pluginAlias": "yamaha-avr",
3 | "pluginType": "platform",
4 | "singular": true,
5 | "schema": {
6 | "type": "object",
7 | "properties": {
8 | "ip": {
9 | "title": "IP Address",
10 | "type": "string",
11 | "required": true,
12 | "default": ""
13 | },
14 | "name": {
15 | "title": "Name",
16 | "type": "string",
17 | "required": false,
18 | "default": "Yamaha AVR"
19 | },
20 | "cacheDirectory": {
21 | "title": "Cache Directory",
22 | "description": "Default: ~/.homebridge/.yamahaAVR",
23 | "type": "string",
24 | "required": false
25 | },
26 | "enablePureDirectSwitch": {
27 | "title": "Enable Pure Direct Switch Device",
28 | "type": "boolean",
29 | "default": false,
30 | "required": false
31 | },
32 | "volumeAccessoryEnabled": {
33 | "title": "Enable Volume Devices",
34 | "type": "boolean",
35 | "default": false,
36 | "required": false
37 | },
38 | "zone2Enabled": {
39 | "title": "Enable Zone 2",
40 | "type": "boolean",
41 | "default": false,
42 | "required": false
43 | },
44 | "zone3Enabled": {
45 | "title": "Enable Zone 3",
46 | "type": "boolean",
47 | "default": false,
48 | "required": false
49 | },
50 | "zone4Enabled": {
51 | "title": "Enable Zone 4",
52 | "type": "boolean",
53 | "default": false,
54 | "required": false
55 | }
56 | }
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/nodemon.json:
--------------------------------------------------------------------------------
1 | {
2 | "watch": [
3 | "src"
4 | ],
5 | "ext": "ts",
6 | "ignore": [],
7 | "exec": "tsc && homebridge -I -D",
8 | "signal": "SIGTERM",
9 | "env": {
10 | "NODE_OPTIONS": "--trace-warnings"
11 | }
12 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "license": "UNLICENSED",
3 | "displayName": "Yamaha AVR",
4 | "name": "homebridge-yamaha-avr",
5 | "version": "3.0.1",
6 | "description": "homebridge-plugin - Add a Yamaha AVR as a HomeKit Audio Receiver with Power, Input, Volume & Remote Control",
7 | "author": {
8 | "name": "ACDR",
9 | "email": "github@acdr.dev",
10 | "url": "https://github.com/ACDR"
11 | },
12 | "repository": {
13 | "type": "git",
14 | "url": "git://github.com/ACDR/homebridge-yamaha-avr.git"
15 | },
16 | "bugs": {
17 | "url": "https://github.com/ACDR/homebridge-yamaha-avr/issues"
18 | },
19 | "engines": {
20 | "node": ">=14.15.4",
21 | "homebridge": ">=1.3.5"
22 | },
23 | "dependencies": {
24 | "fs-extra": "^10.1.0",
25 | "node-fetch": "^3.2.10"
26 | },
27 | "devDependencies": {
28 | "@types/fs-extra": "^9.0.13",
29 | "@types/node": "^18.7.13",
30 | "@types/node-persist": "^3.1.0",
31 | "@typescript-eslint/eslint-plugin": "^5.35.1",
32 | "@typescript-eslint/parser": "^5.35.1",
33 | "eslint": "^7.10.0",
34 | "eslint-config-prettier": "^8.5.0",
35 | "eslint-plugin-prettier": "^4.2.1",
36 | "homebridge": "^1.3.9",
37 | "nodemon": "^2.0.19",
38 | "prettier": "^2.7.1",
39 | "rimraf": "^3.0.2",
40 | "ts-node": "^10.9.1",
41 | "typescript": "4.7.4"
42 | },
43 | "type": "module",
44 | "main": "dist/index.js",
45 | "exports": "./dist/index.js",
46 | "scripts": {
47 | "watch": "npm run build && npm link && nodemon",
48 | "build": "rimraf ./dist && tsc",
49 | "prepublishOnly": "npm run lint && npm run build",
50 | "publish:stable": "npm publish --tag stable",
51 | "publish:beta": "npm publish --tag beta",
52 | "lint": "eslint src/**.ts --max-warnings=0",
53 | "lint:fix": "eslint src/**.ts --max-warnings=0 --fix"
54 | },
55 | "keywords": [
56 | "homebridge-plugin",
57 | "YamahaAVR",
58 | "Yamaha",
59 | "AVR",
60 | "Receiver"
61 | ]
62 | }
63 |
--------------------------------------------------------------------------------
/src/accessory.ts:
--------------------------------------------------------------------------------
1 | import { Service, PlatformAccessory, CharacteristicValue } from 'homebridge';
2 | import fetch, { Response } from 'node-fetch';
3 |
4 | import { YamahaAVRPlatform } from './platform.js';
5 | import { StorageService } from './storageService.js';
6 | import {
7 | AccessoryContext,
8 | BaseResponse,
9 | Cursor,
10 | Features,
11 | Input,
12 | MainZoneRemoteCode,
13 | NameText,
14 | Zone,
15 | ZoneStatus,
16 | } from './types.js';
17 | import { getZoneStatus } from './utils/getZoneStatus.js';
18 |
19 | interface CachedServiceData {
20 | Identifier: number;
21 | CurrentVisibilityState: number;
22 | ConfiguredName: string;
23 | }
24 |
25 | export class YamahaAVRAccessory {
26 | private baseApiUrl: AccessoryContext['device']['baseApiUrl'];
27 | private cacheDirectory: string;
28 | private service: Service;
29 | private inputServices: Service[] = [];
30 | private storageService: StorageService;
31 |
32 | private state: {
33 | isPlaying: boolean; // TODO: Investigaste a better way of tracking "playing" state
34 | inputs: Input[];
35 | connectionError: boolean;
36 | } = {
37 | isPlaying: true,
38 | inputs: [],
39 | connectionError: false,
40 | };
41 |
42 | constructor(
43 | private readonly platform: YamahaAVRPlatform,
44 | private readonly accessory: PlatformAccessory,
45 | private readonly zone: Zone['id'],
46 | ) {
47 | this.cacheDirectory = this.platform.config.cacheDirectory
48 | ? `${this.platform.config.cacheDirectory}/${this.zone}`.replace('//', '/')
49 | : this.platform.api.user.storagePath() + '/.yamahaAVR/' + this.zone;
50 | this.storageService = new StorageService(this.cacheDirectory);
51 | this.storageService.initSync();
52 |
53 | this.platform.log.debug('cache directory', this.cacheDirectory);
54 |
55 | // set the AVR accessory information
56 | this.accessory
57 | .getService(this.platform.Service.AccessoryInformation)!
58 | .setCharacteristic(this.platform.Characteristic.Manufacturer, 'Yamaha')
59 | .setCharacteristic(this.platform.Characteristic.Model, this.accessory.context.device.modelName)
60 | .setCharacteristic(this.platform.Characteristic.SerialNumber, this.accessory.context.device.systemId)
61 | .setCharacteristic(
62 | this.platform.Characteristic.FirmwareRevision,
63 | `${this.accessory.context.device.firmwareVersion}`,
64 | );
65 |
66 | this.service = this.accessory.addService(this.platform.Service.Television);
67 |
68 | this.baseApiUrl = this.accessory.context.device.baseApiUrl;
69 | this.init();
70 |
71 | // regularly ping the AVR to keep power/input state syncronised
72 | setInterval(this.updateAVRState.bind(this), 5000);
73 | }
74 |
75 | async init() {
76 | try {
77 | await this.updateInputSources();
78 | await this.createTVService();
79 | await this.createTVSpeakerService();
80 | await this.createInputSourceServices();
81 | } catch (err) {
82 | this.platform.log.error(err as string);
83 | }
84 | }
85 |
86 | async createTVService() {
87 | // Set Television Service Name & Discovery Mode
88 | this.service
89 | .setCharacteristic(this.platform.Characteristic.ConfiguredName, this.accessory.context.device.displayName)
90 | .setCharacteristic(
91 | this.platform.Characteristic.SleepDiscoveryMode,
92 | this.platform.Characteristic.SleepDiscoveryMode.ALWAYS_DISCOVERABLE,
93 | );
94 |
95 | // Power State Get/Set
96 | this.service
97 | .getCharacteristic(this.platform.Characteristic.Active)
98 | .onSet(this.setPowerState.bind(this))
99 | .onGet(this.getPowerState.bind(this));
100 |
101 | // Input Source Get/Set
102 | this.service
103 | .getCharacteristic(this.platform.Characteristic.ActiveIdentifier)
104 | .onSet(this.setInputState.bind(this))
105 | .onGet(this.getInputState.bind(this));
106 |
107 | // Remote Key Set
108 | this.service.getCharacteristic(this.platform.Characteristic.RemoteKey).onSet(this.setRemoteKey.bind(this));
109 |
110 | return;
111 | }
112 |
113 | async createTVSpeakerService() {
114 | const speakerService = this.accessory.addService(this.platform.Service.TelevisionSpeaker);
115 |
116 | speakerService
117 | .setCharacteristic(this.platform.Characteristic.Active, this.platform.Characteristic.Active.ACTIVE)
118 | .setCharacteristic(
119 | this.platform.Characteristic.VolumeControlType,
120 | this.platform.Characteristic.VolumeControlType.ABSOLUTE,
121 | );
122 |
123 | // handle volume control
124 | speakerService.getCharacteristic(this.platform.Characteristic.VolumeSelector).onSet(this.setVolume.bind(this));
125 |
126 | return;
127 | }
128 |
129 | async createInputSourceServices() {
130 | this.state.inputs.forEach(async (input, i) => {
131 | const cachedService = await this.storageService.getItem(input.id);
132 |
133 | try {
134 | const inputService = this.accessory.addService(this.platform.Service.InputSource, input.text, input.id);
135 |
136 | inputService
137 | .setCharacteristic(this.platform.Characteristic.Identifier, i)
138 | .setCharacteristic(this.platform.Characteristic.Name, input.text)
139 | .setCharacteristic(this.platform.Characteristic.ConfiguredName, cachedService?.ConfiguredName || input.text)
140 | .setCharacteristic(
141 | this.platform.Characteristic.IsConfigured,
142 | this.platform.Characteristic.IsConfigured.CONFIGURED,
143 | )
144 | .setCharacteristic(
145 | this.platform.Characteristic.CurrentVisibilityState,
146 | this.platform.Characteristic.CurrentVisibilityState.SHOWN,
147 | )
148 | .setCharacteristic(
149 | this.platform.Characteristic.InputSourceType,
150 | this.platform.Characteristic.InputSourceType.APPLICATION,
151 | )
152 | .setCharacteristic(
153 | this.platform.Characteristic.InputDeviceType,
154 | this.platform.Characteristic.InputDeviceType.TV,
155 | );
156 |
157 | // Update input name cache
158 | inputService
159 | .getCharacteristic(this.platform.Characteristic.ConfiguredName)
160 | .onGet(async (): Promise => {
161 | const cachedServiceGet = await this.storageService.getItem(input.id);
162 | return cachedServiceGet?.ConfiguredName || input.text;
163 | })
164 | .onSet((name: CharacteristicValue) => {
165 | const currentConfiguredName = inputService.getCharacteristic(
166 | this.platform.Characteristic.ConfiguredName,
167 | ).value;
168 |
169 | if (name === currentConfiguredName) {
170 | return;
171 | }
172 |
173 | this.platform.log.debug(`Set input (${input.id}) name to ${name} `);
174 |
175 | const configuredName = name || input.text;
176 |
177 | inputService.updateCharacteristic(this.platform.Characteristic.ConfiguredName, configuredName);
178 |
179 | this.storageService.setItemSync(input.id, {
180 | ConfiguredName: configuredName,
181 | CurrentVisibilityState: inputService.getCharacteristic(
182 | this.platform.Characteristic.CurrentVisibilityState,
183 | ).value,
184 | });
185 | });
186 |
187 | // Update input visibility cache
188 | inputService
189 | .getCharacteristic(this.platform.Characteristic.TargetVisibilityState)
190 | .onGet(async (): Promise => {
191 | const cachedServiceGet = await this.storageService.getItem(input.id);
192 | return cachedServiceGet?.CurrentVisibilityState || 0;
193 | })
194 | .onSet((targetVisibilityState: CharacteristicValue) => {
195 | const currentVisbility = inputService.getCharacteristic(
196 | this.platform.Characteristic.CurrentVisibilityState,
197 | ).value;
198 |
199 | if (targetVisibilityState === currentVisbility) {
200 | return;
201 | }
202 |
203 | const isHidden = targetVisibilityState === this.platform.Characteristic.TargetVisibilityState.HIDDEN;
204 |
205 | this.platform.log.debug(`Set input (${input.id}) visibility state to ${isHidden ? 'HIDDEN' : 'SHOWN'} `);
206 |
207 | inputService.updateCharacteristic(
208 | this.platform.Characteristic.CurrentVisibilityState,
209 | targetVisibilityState,
210 | );
211 |
212 | this.storageService.setItemSync(input.id, {
213 | ConfiguredName:
214 | inputService.getCharacteristic(this.platform.Characteristic.ConfiguredName).value || input.text,
215 | CurrentVisibilityState: targetVisibilityState,
216 | });
217 | });
218 |
219 | inputService.getCharacteristic(this.platform.Characteristic.Name).onGet((): CharacteristicValue => input.text);
220 |
221 | if (cachedService) {
222 | if (this.platform.Characteristic.CurrentVisibilityState.SHOWN !== cachedService.CurrentVisibilityState) {
223 | this.platform.log.debug(`Restoring input ${input.id} visibility state from cache`);
224 |
225 | inputService.setCharacteristic(
226 | this.platform.Characteristic.CurrentVisibilityState,
227 | cachedService.CurrentVisibilityState,
228 | );
229 | }
230 |
231 | if (input.text !== cachedService.ConfiguredName && cachedService.ConfiguredName !== '') {
232 | this.platform.log.debug(`Restoring input ${input.id} configured name from cache`);
233 | inputService.setCharacteristic(this.platform.Characteristic.ConfiguredName, cachedService.ConfiguredName);
234 | }
235 | }
236 |
237 | this.service.addLinkedService(inputService);
238 | this.inputServices.push(inputService);
239 |
240 | try {
241 | // Cache Data
242 | const name = inputService.getCharacteristic(this.platform.Characteristic.ConfiguredName).value || input.text;
243 | const visibility = inputService.getCharacteristic(this.platform.Characteristic.CurrentVisibilityState).value;
244 |
245 | if (cachedService?.ConfiguredName === name && cachedService.CurrentVisibilityState === visibility) {
246 | return;
247 | }
248 |
249 | this.platform.log.debug(
250 | `Cache input (${input.id}). Name: "${name}", Visibility: "${visibility ? 'HIDDEN' : 'SHOWN'}" `,
251 | );
252 |
253 | this.storageService.setItemSync(input.id, {
254 | ConfiguredName: name,
255 | CurrentVisibilityState: visibility,
256 | });
257 |
258 | if (this.inputServices.length === this.state.inputs.length) {
259 | return;
260 | }
261 | } catch (err) {
262 | this.platform.log.error(
263 | `
264 | Could not write to cache.
265 | Please check your Homebridge instance has permission to write to
266 | "${this.cacheDirectory}"
267 | or set a different cache directory using the "cacheDirectory" config property.
268 | `,
269 | );
270 | }
271 | } catch (err) {
272 | this.platform.log.error(`
273 | Failed to add input service ${input.id}:
274 | ${err}
275 | `);
276 | }
277 | });
278 | }
279 |
280 | async updateInputSources() {
281 | try {
282 | const featuresResponse = await fetch(`${this.baseApiUrl}/system/getFeatures`);
283 | const features = (await featuresResponse.json()) as Features;
284 | const zoneInputs = features.zone.find((zone) => zone.id === this.zone)?.input_list;
285 |
286 | if (!zoneInputs) {
287 | throw new Error();
288 | }
289 |
290 | const getNameTextResponse = await fetch(`${this.baseApiUrl}/system/getNameText`);
291 | const nameText = (await getNameTextResponse.json()) as NameText;
292 |
293 | this.state.inputs = nameText.input_list.filter((input) => zoneInputs.includes(input.id));
294 | } catch {
295 | this.platform.log.error(`
296 | Failed to get available inputs from ${this.platform.config.name}.
297 | Please verify the AVR is connected and accessible at ${this.platform.config.ip}
298 | `);
299 | }
300 | }
301 |
302 | async updateAVRState() {
303 | try {
304 | const zoneStatus = await getZoneStatus(this.platform, this.accessory, this.zone);
305 |
306 | if (!zoneStatus) {
307 | throw new Error();
308 | }
309 |
310 | this.platform.log.debug(`AVR PING`, { power: zoneStatus.power, input: zoneStatus.input });
311 |
312 | this.service.updateCharacteristic(this.platform.Characteristic.Active, zoneStatus.power === 'on');
313 |
314 | this.service.updateCharacteristic(
315 | this.platform.Characteristic.ActiveIdentifier,
316 | this.state.inputs.findIndex((input) => input.id === zoneStatus.input),
317 | );
318 |
319 | if (this.state.connectionError) {
320 | this.state.connectionError = false;
321 | this.platform.log.info(`Communication with Yamaha AVR at ${this.platform.config.ip} restored`);
322 | }
323 | } catch (error) {
324 | if (this.state.connectionError) {
325 | return;
326 | }
327 |
328 | this.state.connectionError = true;
329 | this.platform.log.error(`
330 | Cannot communicate with Yamaha AVR at ${this.platform.config.ip}.
331 | Connection will be restored automatically when the AVR begins responding.
332 | `);
333 | }
334 | }
335 |
336 | async getPowerState(): Promise {
337 | const zoneStatus = await getZoneStatus(this.platform, this.accessory, this.zone);
338 |
339 | if (!zoneStatus) {
340 | return false;
341 | }
342 |
343 | return zoneStatus.power === 'on';
344 | }
345 |
346 | async setPowerState(state: CharacteristicValue) {
347 | try {
348 | let setPowerResponse: Response;
349 |
350 | if (state) {
351 | setPowerResponse = await fetch(`${this.baseApiUrl}/${this.zone}/setPower?power=on`);
352 | } else {
353 | setPowerResponse = await fetch(`${this.baseApiUrl}/${this.zone}/setPower?power=standby`);
354 | }
355 |
356 | const responseJson = (await setPowerResponse.json()) as BaseResponse;
357 |
358 | if (responseJson.response_code !== 0) {
359 | throw new Error('Failed to set zone power');
360 | }
361 | } catch (error) {
362 | this.platform.log.error((error as Error).message);
363 | }
364 | }
365 |
366 | async setRemoteKey(remoteKey: CharacteristicValue) {
367 | try {
368 | const sendRemoteCode = async (remoteKey: MainZoneRemoteCode) => {
369 | const sendIrCodeResponse = await fetch(`${this.baseApiUrl}/system/sendIrCode?code=${remoteKey}`);
370 | const responseJson = (await sendIrCodeResponse.json()) as BaseResponse;
371 |
372 | if (responseJson.response_code !== 0) {
373 | throw new Error('Failed to send ir code');
374 | }
375 | };
376 |
377 | const controlCursor = async (cursor: Cursor) => {
378 | const controlCursorResponse = await fetch(`${this.baseApiUrl}/${this.zone}/controlCursor?cursor=${cursor}`);
379 | const responseJson = (await controlCursorResponse.json()) as BaseResponse;
380 | if (responseJson.response_code !== 0) {
381 | throw new Error('Failed to control cursor');
382 | }
383 | };
384 |
385 | switch (remoteKey) {
386 | case this.platform.Characteristic.RemoteKey.REWIND:
387 | this.platform.log.info('set Remote Key Pressed: REWIND');
388 | sendRemoteCode(MainZoneRemoteCode.SEARCH_BACK);
389 | break;
390 |
391 | case this.platform.Characteristic.RemoteKey.FAST_FORWARD:
392 | this.platform.log.info('set Remote Key Pressed: FAST_FORWARD');
393 | sendRemoteCode(MainZoneRemoteCode.SEARCH_FWD);
394 | break;
395 |
396 | case this.platform.Characteristic.RemoteKey.NEXT_TRACK:
397 | this.platform.log.info('set Remote Key Pressed: NEXT_TRACK');
398 | sendRemoteCode(MainZoneRemoteCode.SKIP_FWD);
399 | break;
400 |
401 | case this.platform.Characteristic.RemoteKey.PREVIOUS_TRACK:
402 | this.platform.log.info('set Remote Key Pressed: PREVIOUS_TRACK');
403 | sendRemoteCode(MainZoneRemoteCode.SKIP_BACK);
404 | break;
405 |
406 | case this.platform.Characteristic.RemoteKey.ARROW_UP:
407 | this.platform.log.info('set Remote Key Pressed: ARROW_UP');
408 | controlCursor('up');
409 | break;
410 |
411 | case this.platform.Characteristic.RemoteKey.ARROW_DOWN:
412 | this.platform.log.info('set Remote Key Pressed: ARROW_DOWN');
413 | controlCursor('down');
414 | break;
415 |
416 | case this.platform.Characteristic.RemoteKey.ARROW_LEFT:
417 | this.platform.log.info('set Remote Key Pressed: ARROW_LEFT');
418 | controlCursor('left');
419 | break;
420 |
421 | case this.platform.Characteristic.RemoteKey.ARROW_RIGHT:
422 | this.platform.log.info('set Remote Key Pressed: ARROW_RIGHT');
423 | controlCursor('right');
424 | break;
425 |
426 | case this.platform.Characteristic.RemoteKey.SELECT:
427 | this.platform.log.info('set Remote Key Pressed: SELECT');
428 | controlCursor('select');
429 | break;
430 |
431 | case this.platform.Characteristic.RemoteKey.BACK:
432 | this.platform.log.info('set Remote Key Pressed: BACK');
433 | controlCursor('return');
434 | break;
435 |
436 | case this.platform.Characteristic.RemoteKey.EXIT:
437 | this.platform.log.info('set Remote Key Pressed: EXIT');
438 | sendRemoteCode(MainZoneRemoteCode.TOP_MENU);
439 | break;
440 |
441 | case this.platform.Characteristic.RemoteKey.PLAY_PAUSE:
442 | this.platform.log.info('set Remote Key Pressed: PLAY_PAUSE');
443 | if (this.state.isPlaying) {
444 | sendRemoteCode(MainZoneRemoteCode.PAUSE);
445 | } else {
446 | sendRemoteCode(MainZoneRemoteCode.PLAY);
447 | }
448 |
449 | this.state.isPlaying = !this.state.isPlaying;
450 |
451 | break;
452 |
453 | case this.platform.Characteristic.RemoteKey.INFORMATION:
454 | this.platform.log.info('set Remote Key Pressed: INFORMATION');
455 | // We'll use the info button to flick through inputs
456 | sendRemoteCode(MainZoneRemoteCode.INPUT_FWD);
457 | break;
458 |
459 | default:
460 | this.platform.log.info('unhandled Remote Key Pressed');
461 | break;
462 | }
463 | } catch (error) {
464 | this.platform.log.error((error as Error).message);
465 | }
466 | }
467 |
468 | async setVolume(direction: CharacteristicValue) {
469 | try {
470 | const zoneStatusResponse = await fetch(`${this.baseApiUrl}/${this.zone}/getStatus`);
471 | const zoneStatus = (await zoneStatusResponse.json()) as ZoneStatus;
472 |
473 | if (zoneStatus.response_code !== 0) {
474 | throw new Error('Failed to set zone volume');
475 | }
476 |
477 | const currentVolume = zoneStatus.volume;
478 | const volumeStep = 5;
479 |
480 | let setVolumeResponse: Response;
481 |
482 | if (direction === 0) {
483 | this.platform.log.info('Volume Up', currentVolume + volumeStep);
484 | setVolumeResponse = await fetch(
485 | `${this.baseApiUrl}/${this.zone}/setVolume?power=${currentVolume + volumeStep}`,
486 | );
487 | } else {
488 | this.platform.log.info('Volume Down', currentVolume - volumeStep);
489 | setVolumeResponse = await fetch(
490 | `${this.baseApiUrl}/${this.zone}/setVolume?power=${currentVolume - volumeStep}`,
491 | );
492 | }
493 |
494 | const responseJson = (await setVolumeResponse.json()) as BaseResponse;
495 |
496 | if (responseJson.response_code !== 0) {
497 | throw new Error('Failed to set zone volume');
498 | }
499 | } catch (error) {
500 | this.platform.log.error((error as Error).message);
501 | }
502 | }
503 |
504 | async getInputState(): Promise {
505 | const zoneStatus = await getZoneStatus(this.platform, this.accessory, this.zone);
506 |
507 | if (!zoneStatus) {
508 | return 0;
509 | }
510 |
511 | this.platform.log.info(`Current ${this.zone} input: ${zoneStatus.input}`);
512 |
513 | return this.state.inputs.findIndex((input) => input.id === zoneStatus.input);
514 | }
515 |
516 | async setInputState(inputIndex: CharacteristicValue) {
517 | try {
518 | if (typeof inputIndex !== 'number') {
519 | return;
520 | }
521 |
522 | const setInputResponse = await fetch(
523 | `${this.baseApiUrl}/${this.zone}/setInput?input=${this.state.inputs[inputIndex].id}`,
524 | );
525 | const responseJson = (await setInputResponse.json()) as BaseResponse;
526 |
527 | if (responseJson.response_code !== 0) {
528 | throw new Error('Failed to set zone input');
529 | }
530 |
531 | this.platform.log.info(`Set input: ${this.state.inputs[inputIndex].id}`);
532 | } catch (error) {
533 | this.platform.log.error((error as Error).message);
534 | }
535 | }
536 | }
537 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { API } from 'homebridge';
2 |
3 | import { PLATFORM_NAME } from './settings.js';
4 | import { YamahaAVRPlatform } from './platform.js';
5 |
6 | export default (api: API) => {
7 | api.registerPlatform(PLATFORM_NAME, YamahaAVRPlatform);
8 | };
9 |
--------------------------------------------------------------------------------
/src/platform.ts:
--------------------------------------------------------------------------------
1 | import {
2 | API,
3 | IndependentPlatformPlugin,
4 | Logger,
5 | PlatformConfig,
6 | Service,
7 | Characteristic,
8 | PlatformAccessory,
9 | } from 'homebridge';
10 | import fetch from 'node-fetch';
11 |
12 | import { YamahaAVRAccessory } from './accessory.js';
13 | import { YamahaVolumeAccessory } from './volumeAccessory.js';
14 | import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js';
15 | import { AccessoryContext, DeviceInfo, Features, Zone } from './types.js';
16 | import { YamahaPureDirectAccessory } from './pureDirectAccessory.js';
17 |
18 | export class YamahaAVRPlatform implements IndependentPlatformPlugin {
19 | public readonly Service: typeof Service = this.api.hap.Service;
20 | public readonly Characteristic: typeof Characteristic = this.api.hap.Characteristic;
21 | public readonly platformAccessories: PlatformAccessory[] = [];
22 | public readonly externalAccessories: PlatformAccessory[] = [];
23 |
24 | constructor(public readonly log: Logger, public readonly config: PlatformConfig, public readonly api: API) {
25 | this.log.debug('Finished initializing platform:', this.config.name);
26 |
27 | this.api.on('didFinishLaunching', () => {
28 | if (!this.config.ip) {
29 | this.log.error('IP address has not been set.');
30 | return;
31 | }
32 |
33 | this.discoverAVR();
34 | });
35 | }
36 |
37 | configureAccessory(accessory: PlatformAccessory) {
38 | this.log.info('Loading accessory from cache:', accessory.displayName);
39 | this.platformAccessories.push(accessory);
40 | }
41 |
42 | async discoverAVR() {
43 | try {
44 | const baseApiUrl = `http://${this.config.ip}/YamahaExtendedControl/v1`;
45 | const deviceInfoResponse = await fetch(`${baseApiUrl}/system/getDeviceInfo`);
46 | const deviceInfo = (await deviceInfoResponse.json()) as DeviceInfo;
47 |
48 | const featuresResponse = await fetch(`${baseApiUrl}/system/getFeatures`);
49 | const features = (await featuresResponse.json()) as Features;
50 |
51 | if (deviceInfo.response_code !== 0) {
52 | throw new Error();
53 | }
54 |
55 | const device: AccessoryContext['device'] = {
56 | displayName: this.config.name ?? `Yamaha ${deviceInfo.model_name}`,
57 | modelName: deviceInfo.model_name,
58 | systemId: deviceInfo.system_id,
59 | firmwareVersion: deviceInfo.system_version,
60 | baseApiUrl,
61 | };
62 |
63 | if (this.config.enablePureDirectSwitch) {
64 | await this.createPureDirectAccessory(device);
65 | }
66 |
67 | await this.createZoneAccessories(device, 'main');
68 |
69 | features.zone.length > 1 && (await this.createZoneAccessories(device, 'zone2'));
70 | features.zone.length > 2 && (await this.createZoneAccessories(device, 'zone3'));
71 | features.zone.length > 3 && (await this.createZoneAccessories(device, 'zone4'));
72 |
73 | if (this.externalAccessories.length > 0) {
74 | this.api.publishExternalAccessories(PLUGIN_NAME, this.externalAccessories);
75 | }
76 | } catch {
77 | this.log.error(`
78 | Failed to get system config from ${this.config.name}. Please verify the AVR is connected and accessible at ${this.config.ip}
79 | `);
80 | }
81 | }
82 |
83 | async createZoneAccessories(device, zone) {
84 | if (zone !== 'main' && !this.config[`${zone}Enabled`]) {
85 | return;
86 | }
87 |
88 | const avrAccessory = await this.createAVRAccessory(device, zone);
89 | this.externalAccessories.push(avrAccessory);
90 |
91 | if (this.config.volumeAccessoryEnabled) {
92 | await this.createVolumeAccessory(device, zone);
93 | }
94 | }
95 |
96 | async createAVRAccessory(device: AccessoryContext['device'], zone: Zone['id']): Promise {
97 | let uuid = `${device.systemId}_${this.config.ip}`;
98 |
99 | if (zone !== 'main') {
100 | uuid = `${uuid}_${zone}`;
101 | }
102 |
103 | uuid = this.api.hap.uuid.generate(uuid);
104 |
105 | const accessory = new this.api.platformAccessory(
106 | `${device.displayName} ${zone}`,
107 | uuid,
108 | this.api.hap.Categories.AUDIO_RECEIVER,
109 | );
110 |
111 | accessory.context = { device };
112 |
113 | new YamahaAVRAccessory(this, accessory, zone);
114 |
115 | return accessory;
116 | }
117 |
118 | async createVolumeAccessory(device: AccessoryContext['device'], zone: Zone['id']): Promise {
119 | let uuid = `${device.systemId}_${this.config.ip}_volume`;
120 |
121 | if (zone !== 'main') {
122 | uuid = `${uuid}_${zone}`;
123 | }
124 |
125 | uuid = this.api.hap.uuid.generate(uuid);
126 |
127 | const accessory = new this.api.platformAccessory(
128 | `AVR Vol. ${zone}`,
129 | uuid,
130 | this.api.hap.Categories.FAN,
131 | );
132 |
133 | accessory.context = { device };
134 |
135 | new YamahaVolumeAccessory(this, accessory, zone);
136 |
137 | const existingAccessory = this.platformAccessories.find((accessory) => accessory.UUID === uuid);
138 | if (existingAccessory) {
139 | this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [existingAccessory]);
140 | }
141 |
142 | this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
143 | }
144 |
145 | async createPureDirectAccessory(device: AccessoryContext['device']): Promise {
146 | const uuid = this.api.hap.uuid.generate(`${device.systemId}_${this.config.ip}_pureDirect`);
147 |
148 | const accessory = new this.api.platformAccessory(
149 | 'AVR Pure Direct',
150 | uuid,
151 | this.api.hap.Categories.SWITCH,
152 | );
153 |
154 | accessory.context = { device };
155 |
156 | new YamahaPureDirectAccessory(this, accessory);
157 |
158 | const existingAccessory = this.platformAccessories.find((accessory) => accessory.UUID === uuid);
159 | if (existingAccessory) {
160 | this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [existingAccessory]);
161 | }
162 |
163 | this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
164 | }
165 | }
166 |
--------------------------------------------------------------------------------
/src/pureDirectAccessory.ts:
--------------------------------------------------------------------------------
1 | import { Service, PlatformAccessory, CharacteristicValue } from 'homebridge';
2 | import fetch from 'node-fetch';
3 |
4 | import { YamahaAVRPlatform } from './platform.js';
5 | import { AccessoryContext, BaseResponse } from './types.js';
6 | import { getZoneStatus } from './utils/getZoneStatus.js';
7 |
8 | export class YamahaPureDirectAccessory {
9 | private baseApiUrl: AccessoryContext['device']['baseApiUrl'];
10 | private service: Service;
11 |
12 | constructor(
13 | private readonly platform: YamahaAVRPlatform,
14 | private readonly accessory: PlatformAccessory,
15 | ) {
16 | // set the AVR accessory information
17 | this.accessory
18 | .getService(this.platform.Service.AccessoryInformation)!
19 | .setCharacteristic(this.platform.Characteristic.Manufacturer, 'Yamaha')
20 | .setCharacteristic(this.platform.Characteristic.Model, this.accessory.context.device.modelName)
21 | .setCharacteristic(this.platform.Characteristic.SerialNumber, this.accessory.context.device.systemId)
22 | .setCharacteristic(
23 | this.platform.Characteristic.FirmwareRevision,
24 | `${this.accessory.context.device.firmwareVersion}`,
25 | );
26 |
27 | this.service = this.accessory.addService(this.platform.Service.Switch);
28 |
29 | this.baseApiUrl = this.accessory.context.device.baseApiUrl;
30 | this.init();
31 |
32 | // regularly ping the AVR to keep power/input state syncronised
33 | setInterval(this.updateState.bind(this), 5000);
34 | }
35 |
36 | async init() {
37 | try {
38 | await this.createService();
39 | } catch (err) {
40 | this.platform.log.error(err as string);
41 | }
42 | }
43 |
44 | async createService() {
45 | this.service
46 | .getCharacteristic(this.platform.Characteristic.On)
47 | .onGet(this.getState.bind(this))
48 | .onSet(this.setState.bind(this));
49 | }
50 |
51 | async updateState() {
52 | const zoneStatus = await getZoneStatus(this.platform, this.accessory, 'main');
53 |
54 | if (!zoneStatus) {
55 | return;
56 | }
57 |
58 | this.service.updateCharacteristic(this.platform.Characteristic.On, zoneStatus.pure_direct);
59 | }
60 |
61 | async getState(): Promise {
62 | const zoneStatus = await getZoneStatus(this.platform, this.accessory, 'main');
63 |
64 | if (!zoneStatus) {
65 | return false;
66 | }
67 |
68 | return zoneStatus.pure_direct;
69 | }
70 |
71 | async setState(state: CharacteristicValue) {
72 | try {
73 | const setPureDirectResponse = await fetch(`${this.baseApiUrl}/${'main'}/setPureDirect?enable=${state}`);
74 |
75 | const responseJson = (await setPureDirectResponse.json()) as BaseResponse;
76 |
77 | if (responseJson.response_code !== 0) {
78 | throw new Error('Failed to set pure direct');
79 | }
80 | } catch (error) {
81 | this.platform.log.error((error as Error).message);
82 | }
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/src/settings.ts:
--------------------------------------------------------------------------------
1 | export const PLATFORM_NAME = 'yamaha-avr';
2 | export const PLUGIN_NAME = 'homebridge-yamaha-avr';
3 |
--------------------------------------------------------------------------------
/src/storageService.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs-extra';
2 | import path from 'path';
3 |
4 | export class StorageService {
5 | constructor(public baseDirectory: string) {}
6 |
7 | public initSync(): void {
8 | return fs.ensureDirSync(this.baseDirectory);
9 | }
10 |
11 | public getItemSync(itemName: string): T | null {
12 | const filePath = path.resolve(this.baseDirectory, itemName);
13 |
14 | if (!fs.pathExistsSync(filePath)) {
15 | return null;
16 | }
17 |
18 | return fs.readJsonSync(filePath);
19 | }
20 |
21 | public async getItem(itemName: string): Promise {
22 | const filePath = path.resolve(this.baseDirectory, itemName);
23 |
24 | if (!(await fs.pathExists(filePath))) {
25 | return null;
26 | }
27 |
28 | return await fs.readJson(filePath);
29 | }
30 |
31 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
32 | public setItemSync(itemName: string, data: Record | Array): void {
33 | return fs.writeJsonSync(path.resolve(this.baseDirectory, itemName), data);
34 | }
35 |
36 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
37 | public setItem(itemName: string, data: Record | Array): Promise {
38 | return fs.writeJson(path.resolve(this.baseDirectory, itemName), data);
39 | }
40 |
41 | public copyItem(srcItemName: string, destItemName: string): Promise {
42 | return fs.copyFile(path.resolve(this.baseDirectory, srcItemName), path.resolve(this.baseDirectory, destItemName));
43 | }
44 |
45 | public copyItemSync(srcItemName: string, destItemName: string): void {
46 | return fs.copyFileSync(
47 | path.resolve(this.baseDirectory, srcItemName),
48 | path.resolve(this.baseDirectory, destItemName),
49 | );
50 | }
51 |
52 | public removeItemSync(itemName: string): void {
53 | return fs.removeSync(path.resolve(this.baseDirectory, itemName));
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | export interface BaseResponse {
2 | response_code: number;
3 | }
4 |
5 | export interface DeviceInfo {
6 | response_code: number;
7 | model_name: string;
8 | destination: string;
9 | device_id: string;
10 | system_id: string;
11 | system_version: number;
12 | api_version: number;
13 | netmodule_generation: number;
14 | netmodule_version: string;
15 | netmodule_checksum: string;
16 | serial_number: string;
17 | category_code: number;
18 | operation_mode: string;
19 | update_error_code: string;
20 | net_module_num: number;
21 | update_data_type: number;
22 | }
23 |
24 | export interface Features {
25 | response_code: number;
26 | system: {
27 | zone_num: number;
28 | };
29 | zone: FeatureZone[];
30 | }
31 |
32 | export interface FeatureZone {
33 | id: Zone['id'];
34 | input_list: Input['id'][];
35 | }
36 |
37 | export interface ZoneStatus {
38 | response_code: number;
39 | power: 'on' | 'standby';
40 | sleep: number;
41 | volume: number;
42 | mute: boolean;
43 | max_volume: number;
44 | input: Input['id'];
45 | input_text: Input['text'];
46 | distribution_enable: boolean;
47 | sound_program: SoundProgram['id'];
48 | surr_decoder_type: string;
49 | pure_direct: boolean;
50 | enhancer: boolean;
51 | tone_control: {
52 | mode: string;
53 | bass: number;
54 | treble: number;
55 | };
56 | dialogue_level: number;
57 | dialogue_lift: number;
58 | subwoofer_volume: number;
59 | link_control: string;
60 | link_audio_delay: string;
61 | disable_flags: number;
62 | contents_display: boolean;
63 | actual_volume: {
64 | mode: string;
65 | value: number;
66 | unit: string;
67 | };
68 | party_enable: boolean;
69 | extra_bass: boolean;
70 | adaptive_drc: boolean;
71 | dts_dialogue_control: number;
72 | adaptive_dsp_level: boolean;
73 | }
74 |
75 | export interface Zone {
76 | id: 'main' | 'zone2' | 'zone3' | 'zone4';
77 | text: string;
78 | }
79 |
80 | export interface Input {
81 | id:
82 | | 'cd'
83 | | 'tuner'
84 | | 'multi_ch'
85 | | 'phono'
86 | | 'hdmi1'
87 | | 'hdmi2'
88 | | 'hdmi3'
89 | | 'hdmi4'
90 | | 'hdmi5'
91 | | 'hdmi6'
92 | | 'hdmi7'
93 | | 'hdmi8'
94 | | 'hdmi'
95 | | 'av1'
96 | | 'av2'
97 | | 'av3'
98 | | 'av4'
99 | | 'av5'
100 | | 'av6'
101 | | 'av7'
102 | | 'v_aux'
103 | | 'aux1'
104 | | 'aux2'
105 | | 'aux'
106 | | 'audio1'
107 | | 'audio2'
108 | | 'audio3'
109 | | 'audio4'
110 | | 'audio_cd'
111 | | 'audio'
112 | | 'optical1'
113 | | 'optical2'
114 | | 'optical'
115 | | 'coaxial1'
116 | | 'coaxial2'
117 | | 'coaxial'
118 | | 'digital1'
119 | | 'digital2'
120 | | 'digital'
121 | | 'line1'
122 | | 'line2'
123 | | 'line3'
124 | | 'line_cd'
125 | | 'analog'
126 | | 'tv'
127 | | 'bd_dvd'
128 | | 'usb_dac'
129 | | 'usb'
130 | | 'bluetooth'
131 | | 'server'
132 | | 'net_radio'
133 | | 'rhapsody'
134 | | 'napster'
135 | | 'pandora'
136 | | 'siriusxm'
137 | | 'spotify'
138 | | 'juke'
139 | | 'airplay'
140 | | 'radiko'
141 | | 'qobuz'
142 | | 'mc_link'
143 | | 'main_sync'
144 | | 'none';
145 | text: string;
146 | }
147 |
148 | export interface SoundProgram {
149 | id:
150 | | 'munich_a'
151 | | 'munich_b'
152 | | 'munich'
153 | | 'frankfurt'
154 | | 'stuttgart'
155 | | 'vienna'
156 | | 'amsterdam'
157 | | 'usa_a'
158 | | 'usa_b /tokyo'
159 | | 'freiburg'
160 | | 'royaumont'
161 | | 'chamber'
162 | | 'concert'
163 | | 'village_gate'
164 | | 'village_vanguard /warehouse_loft'
165 | | 'cellar_club'
166 | | 'jazz_club'
167 | | 'roxy_theatre'
168 | | 'bottom_line'
169 | | 'arena'
170 | | 'sports /action_game'
171 | | 'roleplaying_game'
172 | | 'game'
173 | | 'music_video'
174 | | 'music'
175 | | 'recital_opera'
176 | | 'pavilion /disco'
177 | | 'standard'
178 | | 'spectacle'
179 | | 'sci-fi'
180 | | 'adventure'
181 | | 'drama'
182 | | 'talk_show'
183 | | 'tv_program /mono_movie'
184 | | 'movie'
185 | | 'enhanced'
186 | | '2ch_stereo'
187 | | '5ch_stereo'
188 | | '7ch_stereo'
189 | | '9ch_stereo /11ch_stereo'
190 | | 'stereo'
191 | | 'surr_decoder'
192 | | 'my_surround'
193 | | 'target'
194 | | 'straight'
195 | | 'off';
196 | text: string;
197 | }
198 |
199 | export interface NameText {
200 | response_code: number;
201 | zone_list: Zone[];
202 | input_list: Input[];
203 | sound_program_list: SoundProgram[];
204 | }
205 |
206 | export interface AccessoryContext {
207 | device: {
208 | displayName: string;
209 | modelName: DeviceInfo['model_name'];
210 | systemId: DeviceInfo['system_id'];
211 | firmwareVersion: DeviceInfo['system_version'];
212 | baseApiUrl: string;
213 | };
214 | }
215 |
216 | export type Cursor = 'up' | 'down' | 'left' | 'right' | 'select' | 'return';
217 |
218 | export enum MainZoneRemoteCode {
219 | // numeric codes
220 | NUM_1 = '7F0151AE',
221 | NUM_2 = '7F0152AD',
222 | NUM_3 = '7F0153AC',
223 | NUM_4 = '7F0154AB',
224 | NUM_5 = '7F0155AA',
225 | NUM_6 = '7F0156A9',
226 | NUM_7 = '7F0157A8',
227 | NUM_8 = '7F0158A7',
228 | NUM_9 = '7F0159A6',
229 | NUM_0 = '7F015AA5',
230 | NUM_10_PLUS = '7F015BA4',
231 | ENT = '7F015CA3',
232 |
233 | // operations codes
234 | PLAY = '7F016897',
235 | STOP = '7F016996',
236 | PAUSE = '7F016798',
237 | SEARCH_BACK = '7F016A95',
238 | SEARCH_FWD = '7F016B94',
239 | SKIP_BACK = '7F016C93',
240 | SKIP_FWD = '7F016D92',
241 | INPUT_BACK = '7A85235C',
242 | INPUT_FWD = '7A851F60',
243 | FM = '7F015827',
244 | AM = '7F01552A',
245 |
246 | // cursor codes
247 | UP = '7A859D62',
248 | DOWN = '7A859C63',
249 | LEFT = '7A859F60',
250 | RIGHT = '7A859E61',
251 | ENTER = '7A85DE21',
252 | RETURN = '7A85AA55',
253 | LEVEL = '7A858679',
254 | ON_SCREEN = '7A85847B',
255 | OPTION = '7A856B14',
256 | TOP_MENU = '7A85A0DF',
257 | POP_UP_MENU = '7A85A4DB',
258 | }
259 |
260 | export enum Zone2RemoteCode {
261 | // numeric codes
262 | NUM_1 = '7F01718F',
263 | NUM_2 = '7F01728C',
264 | NUM_3 = '7F01738D',
265 | NUM_4 = '7F01748A',
266 | NUM_5 = '7F01758B',
267 | NUM_6 = '7F017688',
268 | NUM_7 = '7F017789',
269 | NUM_8 = '7F017886',
270 | NUM_9 = '7F017986',
271 | NUM_0 = '7F017A84',
272 | NUM_10_PLUS = '7F017B85',
273 | ENT = '7F017C82',
274 |
275 | // operations codes
276 | PLAY = '7F018876',
277 | STOP = '7F018977',
278 | PAUSE = '7F018779',
279 | SEARCH_BACK = '7F018A74',
280 | SEARCH_FWD = '7F018B75',
281 | SKIP_BACK = '7F018C72',
282 | SKIP_FWD = '7F018D73',
283 | FM = '7F015927',
284 | AM = '7F015628',
285 |
286 | // cursor codes
287 | UP = '7A852B55',
288 | DOWN = '7A852C52',
289 | LEFT = '7A852D53',
290 | RIGHT = '7A852E50',
291 | ENTER = '7A852F51',
292 | RETURN = '7A853C42',
293 | OPTION = '7A856C12',
294 | TOP_MENU = '7A85A1DF',
295 | POP_UP_MENU = '7A85A5DB',
296 | }
297 |
--------------------------------------------------------------------------------
/src/utils/getZoneStatus.ts:
--------------------------------------------------------------------------------
1 | import { PlatformAccessory } from 'homebridge';
2 | import fetch from 'node-fetch';
3 | import { YamahaAVRPlatform } from '../platform.js';
4 | import { AccessoryContext, Zone, ZoneStatus } from '../types.js';
5 |
6 | export const getZoneStatus = async (
7 | platform: YamahaAVRPlatform,
8 | accessory: PlatformAccessory,
9 | zone: Zone['id'],
10 | ): Promise => {
11 | const zoneStatusResponse = await fetch(`${accessory.context.device.baseApiUrl}/${zone}/getStatus`);
12 | const zoneStatus = (await zoneStatusResponse.json()) as ZoneStatus;
13 |
14 | if (zoneStatus.response_code !== 0) {
15 | platform.log.error('Failed to fetch zone status');
16 | }
17 |
18 | return zoneStatus;
19 | };
20 |
--------------------------------------------------------------------------------
/src/volumeAccessory.ts:
--------------------------------------------------------------------------------
1 | import { Service, PlatformAccessory, CharacteristicValue } from 'homebridge';
2 | import fetch from 'node-fetch';
3 |
4 | import { YamahaAVRPlatform } from './platform.js';
5 | import { AccessoryContext, BaseResponse, Zone } from './types.js';
6 | import { getZoneStatus } from './utils/getZoneStatus.js';
7 |
8 | export class YamahaVolumeAccessory {
9 | private baseApiUrl: AccessoryContext['device']['baseApiUrl'];
10 | private service: Service;
11 |
12 | constructor(
13 | private readonly platform: YamahaAVRPlatform,
14 | private readonly accessory: PlatformAccessory,
15 | private readonly zone: Zone['id'],
16 | ) {
17 | // set the AVR accessory information
18 | this.accessory
19 | .getService(this.platform.Service.AccessoryInformation)!
20 | .setCharacteristic(this.platform.Characteristic.Manufacturer, 'Yamaha')
21 | .setCharacteristic(this.platform.Characteristic.Model, this.accessory.context.device.modelName)
22 | .setCharacteristic(this.platform.Characteristic.SerialNumber, this.accessory.context.device.systemId)
23 | .setCharacteristic(
24 | this.platform.Characteristic.FirmwareRevision,
25 | `${this.accessory.context.device.firmwareVersion}`,
26 | );
27 |
28 | this.service = this.accessory.addService(this.platform.Service.Fan);
29 |
30 | this.baseApiUrl = this.accessory.context.device.baseApiUrl;
31 | this.init();
32 |
33 | // regularly ping the AVR to keep power/input state syncronised
34 | setInterval(this.updateState.bind(this), 5000);
35 | }
36 |
37 | async init() {
38 | try {
39 | await this.createService();
40 | } catch (err) {
41 | this.platform.log.error(err as string);
42 | }
43 | }
44 |
45 | async createService() {
46 | this.service.setCharacteristic(this.platform.Characteristic.On, true);
47 |
48 | // Mute Get/Set
49 | this.service
50 | .getCharacteristic(this.platform.Characteristic.On)
51 | .onSet(this.setMute.bind(this))
52 | .onGet(this.getMute.bind(this));
53 |
54 | // Volume Get/Set
55 | this.service
56 | .getCharacteristic(this.platform.Characteristic.RotationSpeed)
57 | .onSet(this.setVolume.bind(this))
58 | .onGet(this.getVolume.bind(this));
59 | }
60 |
61 | async updateState() {
62 | const zoneStatus = await getZoneStatus(this.platform, this.accessory, this.zone);
63 |
64 | if (!zoneStatus) {
65 | return;
66 | }
67 |
68 | this.service.updateCharacteristic(this.platform.Characteristic.On, !zoneStatus.mute);
69 | this.service.updateCharacteristic(
70 | this.platform.Characteristic.RotationSpeed,
71 | (zoneStatus.volume / zoneStatus.max_volume) * 100,
72 | );
73 | }
74 |
75 | async getMute(): Promise {
76 | const zoneStatus = await getZoneStatus(this.platform, this.accessory, this.zone);
77 |
78 | if (!zoneStatus) {
79 | return false;
80 | }
81 |
82 | return !zoneStatus.mute;
83 | }
84 |
85 | async setMute(state: CharacteristicValue) {
86 | try {
87 | const setMuteResponse = await fetch(`${this.baseApiUrl}/${this.zone}/setMute?enable=${!state}`);
88 |
89 | const responseJson = (await setMuteResponse.json()) as BaseResponse;
90 |
91 | if (responseJson.response_code !== 0) {
92 | throw new Error('Failed to set zone mute');
93 | }
94 | } catch (error) {
95 | this.platform.log.error((error as Error).message);
96 | }
97 | }
98 |
99 | async getVolume(): Promise {
100 | const zoneStatus = await getZoneStatus(this.platform, this.accessory, this.zone);
101 |
102 | if (!zoneStatus) {
103 | return 50;
104 | }
105 |
106 | return (zoneStatus.volume / zoneStatus.max_volume) * 100;
107 | }
108 |
109 | async setVolume(state: CharacteristicValue) {
110 | try {
111 | const zoneStatus = await getZoneStatus(this.platform, this.accessory, this.zone);
112 |
113 | if (!zoneStatus) {
114 | return;
115 | }
116 |
117 | const setVolumeResponse = await fetch(
118 | `${this.baseApiUrl}/${this.zone}/setVolume?volume=${((Number(state) * zoneStatus.max_volume) / 100).toFixed(
119 | 0,
120 | )}`,
121 | );
122 |
123 | const responseJson = (await setVolumeResponse.json()) as BaseResponse;
124 |
125 | if (responseJson.response_code !== 0) {
126 | throw new Error(`Failed to set zone volume`);
127 | }
128 | } catch (error) {
129 | this.platform.log.error((error as Error).message);
130 | }
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES6",
4 | "module": "ESNext",
5 | "lib": ["ESNext", "DOM"],
6 | "declaration": true,
7 | "declarationMap": true,
8 | "sourceMap": true,
9 | "outDir": "./dist",
10 | "rootDir": "./src",
11 | "strict": true,
12 | "esModuleInterop": true,
13 | "moduleResolution": "node",
14 | "noImplicitAny": false
15 | },
16 | "include": ["src/"],
17 | "exclude": ["node_modules", "**/*.spec.ts"]
18 | }
19 |
--------------------------------------------------------------------------------