├── .github └── workflows │ ├── beta-release.yml │ ├── build.yml │ ├── codeql-analysis.yml │ ├── release-drafter.yml │ ├── release.yml │ └── stale.yml ├── .gitignore ├── .npmignore ├── .vscode ├── extensions.json └── settings.json ├── CHANGELOG.md ├── DEVELOPMENT.md ├── LICENSE ├── README.md ├── eslint.config.js ├── examples ├── basic-ui-server │ ├── config.schema.json │ ├── homebridge-ui │ │ ├── public │ │ │ └── index.html │ │ └── server.js │ ├── package-lock.json │ └── package.json └── push-events │ ├── config.schema.json │ ├── homebridge-ui │ ├── public │ │ └── index.html │ └── server.js │ ├── package-lock.json │ └── package.json ├── package-lock.json ├── package.json ├── src ├── index.ts ├── server.ts ├── ui.interface.ts ├── ui.mock.ts └── ui.ts ├── tsconfig.json └── tsconfig.ui.json /.github/workflows/beta-release.yml: -------------------------------------------------------------------------------- 1 | name: Node-CI Beta 2 | 3 | on: 4 | push: 5 | branches: [beta-*.*.*, beta] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | build_and_test: 10 | uses: homebridge/.github/.github/workflows/nodejs-build-and-test.yml@latest 11 | with: 12 | enable_coverage: false 13 | secrets: 14 | token: ${{ secrets.GITHUB_TOKEN }} 15 | lint: 16 | needs: build_and_test 17 | uses: homebridge/.github/.github/workflows/eslint.yml@latest 18 | 19 | publish: 20 | needs: lint 21 | if: ${{ github.repository == 'homebridge/plugin-ui-utils' }} 22 | uses: homebridge/.github/.github/workflows/npm-publish-esm.yml@latest 23 | with: 24 | tag: beta 25 | dynamically_adjust_version: true 26 | npm_version_command: pre 27 | pre_id: beta 28 | secrets: 29 | npm_auth_token: ${{ secrets.npm_token }} 30 | 31 | # github-releases-to-discord: 32 | # name: Discord Webhooks 33 | # needs: publish 34 | # uses: homebridge/.github/.github/workflows/discord-webhooks.yml@latest 35 | # with: 36 | # title: Plugin UI Utils (Beta) 37 | # description: | 38 | # Version `v${{ needs.publish.outputs.NPM_VERSION }}` 39 | # secrets: 40 | # DISCORD_WEBHOOK_URL_BETA: ${{ secrets.DISCORD_WEBHOOK_URL_LATEST }} 41 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Node Build 2 | 3 | on: 4 | push: 5 | branches: [latest] 6 | pull_request: 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build_and_test: 11 | uses: homebridge/.github/.github/workflows/nodejs-build-and-test.yml@latest 12 | with: 13 | enable_coverage: false 14 | secrets: 15 | token: ${{ secrets.GITHUB_TOKEN }} 16 | lint: 17 | needs: build_and_test 18 | uses: homebridge/.github/.github/workflows/eslint.yml@latest 19 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL 2 | 3 | on: 4 | push: 5 | branches: [latest, beta*] 6 | pull_request: 7 | branches: [latest, beta*] 8 | schedule: 9 | - cron: '17 9 * * 2' 10 | 11 | jobs: 12 | analyze: 13 | uses: homebridge/.github/.github/workflows/codeql-analysis.yml@latest 14 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: [latest] 6 | pull_request: # required for autolabeler 7 | types: [opened, reopened, synchronize] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | stale: 12 | uses: homebridge/.github/.github/workflows/release-drafter.yml@latest 13 | secrets: 14 | token: ${{ secrets.GITHUB_TOKEN }} 15 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Node Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build_and_test: 11 | uses: homebridge/.github/.github/workflows/nodejs-build-and-test.yml@latest 12 | with: 13 | enable_coverage: false 14 | secrets: 15 | token: ${{ secrets.GITHUB_TOKEN }} 16 | 17 | publish: 18 | needs: build_and_test 19 | 20 | if: ${{ github.repository == 'homebridge/plugin-ui-utils' }} 21 | 22 | uses: homebridge/.github/.github/workflows/npm-publish-esm.yml@latest 23 | secrets: 24 | npm_auth_token: ${{ secrets.npm_token }} 25 | 26 | # github-releases-to-discord: 27 | # name: Discord Webhooks 28 | # needs: publish 29 | # uses: homebridge/.github/.github/workflows/discord-webhooks.yml@latest 30 | # with: 31 | # title: Plugin UI Utils 32 | # description: | 33 | # Version `v${{ needs.publish.outputs.NPM_VERSION }}` 34 | # url: 'https://github.com/homebridge/plugin-ui-utils/releases/tag/v${{ needs.publish.outputs.NPM_VERSION }}' 35 | # secrets: 36 | # DISCORD_WEBHOOK_URL_LATEST: ${{ secrets.DISCORD_WEBHOOK_URL_LATEST }} 37 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Stale workflow 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: '45 11 * * *' 7 | 8 | jobs: 9 | stale: 10 | uses: homebridge/.github/.github/workflows/stale.yml@latest 11 | secrets: 12 | token: ${{ secrets.GITHUB_TOKEN }} 13 | -------------------------------------------------------------------------------- /.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 | # Microbundle cache 62 | .rpt2_cache/ 63 | .rts2_cache_cjs/ 64 | .rts2_cache_es/ 65 | .rts2_cache_umd/ 66 | 67 | # Optional REPL history 68 | .node_repl_history 69 | 70 | # Output of 'npm pack' 71 | *.tgz 72 | 73 | # Yarn Integrity file 74 | .yarn-integrity 75 | 76 | # dotenv environment variables file 77 | .env 78 | .env.test 79 | 80 | # parcel-bundler cache (https://parceljs.org/) 81 | .cache 82 | .parcel-cache 83 | 84 | # Next.js build output 85 | .next 86 | 87 | # Nuxt.js build / generate output 88 | .nuxt 89 | dist 90 | 91 | # Gatsby files 92 | .cache/ 93 | # Comment in the public line in if your project uses Gatsby and not Next.js 94 | # https://nextjs.org/blog/next-9-1#public-directory-support 95 | # public 96 | 97 | # vuepress build output 98 | .vuepress/dist 99 | 100 | # Serverless directories 101 | .serverless/ 102 | 103 | # FuseBox cache 104 | .fusebox/ 105 | 106 | # DynamoDB Local files 107 | .dynamodb/ 108 | 109 | # TernJS port file 110 | .tern-port 111 | 112 | # Stores VSCode versions used for testing VSCode extensions 113 | .vscode-test 114 | 115 | # yarn v2 116 | 117 | .yarn/cache 118 | .yarn/unplugged 119 | .yarn/build-state.yml 120 | .pnp.* 121 | 122 | # IDEs 123 | .idea 124 | .DS_Store 125 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Ignore source code 2 | src 3 | examples 4 | 5 | # ------------- Defaults ------------- # 6 | 7 | # gitHub actions 8 | .github 9 | 10 | # eslint 11 | .eslintrc 12 | 13 | # typescript 14 | tsconfig.json 15 | 16 | # vscode 17 | .vscode 18 | 19 | # nodemon 20 | nodemon.json 21 | 22 | # Logs 23 | logs 24 | *.log 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | lerna-debug.log* 29 | 30 | # Diagnostic reports (https://nodejs.org/api/report.html) 31 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 32 | 33 | # Runtime data 34 | pids 35 | *.pid 36 | *.seed 37 | *.pid.lock 38 | 39 | # Directory for instrumented libs generated by jscoverage/JSCover 40 | lib-cov 41 | 42 | # Coverage directory used by tools like istanbul 43 | coverage 44 | *.lcov 45 | 46 | # nyc test coverage 47 | .nyc_output 48 | 49 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 50 | .grunt 51 | 52 | # Bower dependency directory (https://bower.io/) 53 | bower_components 54 | 55 | # node-waf configuration 56 | .lock-wscript 57 | 58 | # Compiled binary addons (https://nodejs.org/api/addons.html) 59 | build/Release 60 | 61 | # Dependency directories 62 | node_modules/ 63 | jspm_packages/ 64 | 65 | # Snowpack dependency directory (https://snowpack.dev/) 66 | web_modules/ 67 | 68 | # TypeScript cache 69 | *.tsbuildinfo 70 | 71 | # Optional npm cache directory 72 | .npm 73 | 74 | # Optional eslint cache 75 | .eslintcache 76 | 77 | # Microbundle cache 78 | .rpt2_cache/ 79 | .rts2_cache_cjs/ 80 | .rts2_cache_es/ 81 | .rts2_cache_umd/ 82 | 83 | # Optional REPL history 84 | .node_repl_history 85 | 86 | # Output of 'npm pack' 87 | *.tgz 88 | 89 | # Yarn Integrity file 90 | .yarn-integrity 91 | 92 | # dotenv environment variables file 93 | .env 94 | .env.test 95 | 96 | # parcel-bundler cache (https://parceljs.org/) 97 | .cache 98 | .parcel-cache 99 | 100 | # Next.js build output 101 | .next 102 | 103 | # Nuxt.js build / generate output 104 | .nuxt 105 | dist 106 | 107 | # Gatsby files 108 | .cache/ 109 | # Comment in the public line in if your project uses Gatsby and not Next.js 110 | # https://nextjs.org/blog/next-9-1#public-directory-support 111 | # public 112 | 113 | # vuepress build output 114 | .vuepress/dist 115 | 116 | # Serverless directories 117 | .serverless/ 118 | 119 | # FuseBox cache 120 | .fusebox/ 121 | 122 | # DynamoDB Local files 123 | .dynamodb/ 124 | 125 | # TernJS port file 126 | .tern-port 127 | 128 | # Stores VSCode versions used for testing VSCode extensions 129 | .vscode-test 130 | 131 | # yarn v2 132 | 133 | .yarn/cache 134 | .yarn/unplugged 135 | .yarn/build-state.yml 136 | .pnp.* 137 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.eol": "\n", 3 | "editor.tabSize": 2, 4 | "editor.insertSpaces": true, 5 | "editor.formatOnSave": true, 6 | "editor.codeActionsOnSave": { 7 | "source.fixAll.eslint": "explicit" 8 | }, 9 | "editor.rulers": [ 10 | 140 11 | ], 12 | "codeQL.githubDatabase.download": "never" 13 | } 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to `@homebridge/plugin-ui-utils` will be documented in this file. This project tries to adhere to [Semantic Versioning](http://semver.org/). 4 | 5 | ## v2.1.0 (2025-06-02) 6 | 7 | ### Changes 8 | 9 | - add method `userCurrentLightingMode` to get the current lighting mode 10 | - requires homebridge ui `^4.75.1-beta.2 || ^5.0.0-beta.76` 11 | 12 | ## v2.0.2 (2025-03-30) 13 | 14 | ### Homebridge UI Bootstrap Version 15 | 16 | Please note that the Boostrap version in the UI was recently updated from `v4` to `v5`. 17 | This may cause some style changes to your custom UIs. 18 | 19 | For more information about the changes, please refer to the [Bootstrap v5 migration guide](https://getbootstrap.com/docs/5.0/migration/). 20 | 21 | ### Changes 22 | 23 | - update `README` to document updated UI bootstrap version 24 | - updated dependencies 25 | - improve typings 26 | 27 | ## v2.0.1 (2025-01-21) 28 | 29 | ### Changes 30 | 31 | - fix: package.json not export `ui.interface` (#23) (@baranwang) 32 | - updated dependencies 33 | 34 | ## v2.0.0 (2024-11-22) 35 | 36 | ### Notable Changes 37 | 38 | - ⚠️ update to esm package 39 | - add methods for enabling and disabling save button 40 | - requires homebridge ui `^5.0.0-beta.4` 41 | 42 | ## v1.0.3 (2024-04-06) 43 | 44 | ### Other Changes 45 | 46 | - update CHANGELOG to match hb repo formats 47 | - update example folder package json files 48 | - update LICENSE file to match hb repo formats 49 | - spelling and grammar in code comments 50 | - update README with hb logo and formatting 51 | - update dependencies 52 | 53 | ## v1.0.2 (2024-03-30) 54 | 55 | ### Other Changes 56 | 57 | - update dependencies 58 | 59 | ## v1.0.1 (2024-01-08) 60 | 61 | ### Other Changes 62 | 63 | - Add discord webhook to notify developers of new updates 64 | - update dependencies 65 | 66 | ## v1.0.0 (2023-10-23) 67 | 68 | ### Other Changes 69 | 70 | - Initial release 71 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Homebridge Custom UI Development FAQ 2 | 3 | ## How to get started developing locally? 4 | 5 | You're probably used to using 6 | 7 | ``` 8 | npm run watch 9 | ``` 10 | 11 | in your plugin's root directory to test functionality while iterating. In order to do something similar when working on your UI, you'll need to install homebridge-ui-config-x as a dev dependency, and then start the homebridge-config-ui-x service itself. 12 | 13 | ``` 14 | npm i --save-dev homebridge-config-ui-x 15 | npx homebridge-config-ui-x 16 | ``` 17 | 18 | Open your browser to port 8080 (default) or the port you've specified. Your `server.js` is started when you click "Settings" in the Plugins view, and terminated when you close the view. To "refresh" the view, you only need to dismiss the model and re-open. 19 | 20 | ## How to prevent asset caching during development? 21 | 22 | 1. Open DevTools 23 | 2. Go to the "Network" tab 24 | 3. Check the "Disable Cache" checkbox at the top 25 | 26 | Caching will now be disabled whenever the DevTools are open. 27 | 28 | See https://stackoverflow.com/a/7000899. 29 | 30 | ## How to use the Angular / Vue / Webpack development server? 31 | 32 | To facilitate streamlined development when using compiled frameworks such as Angular or Vue, the Homebridge UI allows you to load your custom user interface directly from your development server. 33 | 34 | To do this you will need to `private` to `true` in the plugin's `package.json`. 35 | 36 | The Homebridge UI will only allow you to load content from a development webserver with this attribute is set. This is to prevent published plugins from using the feature (either accidentally or intentionally). 37 | 38 | You will need to remove this flag before you can publish your plugin to npm. 39 | 40 | ```json 41 | { 42 | "private": true, 43 | "name": "homebridge-example", 44 | ... 45 | } 46 | ``` 47 | 48 | Next, set the path to your development server in the `config.schema.json` using the `customUiDevServer` attribute: 49 | 50 | ```json 51 | { 52 | "pluginAlias": "homebridge-example", 53 | "pluginType": "platform", 54 | "customUi": true, 55 | "customUiDevServer": "http://localhost:4200", 56 | ... 57 | } 58 | ``` 59 | 60 | Finally, ensure you are starting your development server without "Live Reload" enabled. 61 | 62 | **Angular**: 63 | 64 | ``` 65 | ng serve --no-live-reload 66 | ``` 67 | 68 | **Vue**, in the `vue.config.js` file: 69 | 70 | ```js 71 | module: { 72 | rules: [ 73 | { 74 | test: /\.vue$/, 75 | loader: 'vue-loader', 76 | options: { 77 | hotReload: false // disables Hot Reload 78 | } 79 | } 80 | ] 81 | } 82 | ``` 83 | 84 | You will need to restart the Homebridge UI after making changes to the dev server configuration. 85 | 86 | ## How to get TypeScript types for `window.homebridge`? 87 | 88 | Import this into your project, it will register the types for the `window.homebridge` object: 89 | 90 | ```ts 91 | import '@homebridge/plugin-ui-utils/dist/ui.interface' 92 | ``` 93 | 94 | ## How to test `window.homebridge` using Jest / Karma etc.? 95 | 96 | As `window.homebridge` is injected at run time, you will need to mock the object in your tests. This package provides a class that helps you do this, `MockHomebridgePluginUi`. 97 | 98 | :warning: Do not include `MockHomebridgePluginUi` in your production build! 99 | 100 | Here is a simple example using Jest: 101 | 102 | ```ts 103 | // example.spec.ts 104 | import { MockHomebridgePluginUi } from '@homebridge/plugin-ui-utils/dist/ui.mock' 105 | 106 | describe('TestCustomUi', () => { 107 | let homebridge: MockHomebridgePluginUi 108 | 109 | beforeEach(() => { 110 | homebridge = new MockHomebridgePluginUi() 111 | window.homebridge = homebridge 112 | }) 113 | 114 | it('should return the plugin config and schema when called', async () => { 115 | // mock the config 116 | homebridge.mockPluginConfig = [ 117 | { 118 | platform: 'homebridge-example' 119 | } 120 | ] 121 | 122 | // mock the schema 123 | homebridge.mockPluginSchema = { 124 | pluginAlias: 'homebridge-example', 125 | pluginType: 'platform' 126 | } 127 | 128 | expect(await window.homebridge.getPluginConfig()).toHaveLength(1) 129 | expect(await window.homebridge.getPluginConfigSchema()).toHaveProperty('pluginAlias') 130 | }) 131 | }) 132 | ``` 133 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-2025 Homebridge 4 | Copyright (c) 2020-2023 oznu 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | 6 | # Plugin UI Utils 7 | 8 | [![npm](https://badgen.net/npm/v/@homebridge/plugin-ui-utils)](https://www.npmjs.com/package/@homebridge/plugin-ui-utils) 9 | [![npm](https://badgen.net/npm/dt/@homebridge/plugin-ui-utils)](https://www.npmjs.com/package/@homebridge/plugin-ui-utils) 10 | [![Discord](https://img.shields.io/discord/432663330281226270?color=728ED5&logo=discord&label=discord)](https://discord.gg/kqNCe2D) 11 | 12 | 13 | 14 | The package assists plugin developers creating fully customisable configuration user interfaces for their plugins. 15 | 16 | - [Implementation](#implementation) 17 | - [Project Layout](#project-layout) 18 | - [User Interface API](#user-interface-api) 19 | - [Config](#config) 20 | - [Requests](#requests) 21 | - [Toast Notifications](#toast-notifications) 22 | - [Modal](#modal) 23 | - [Forms](#forms) 24 | - [Events](#events) 25 | - [Plugin / Server Information](#plugin--server-information) 26 | - [Server API](#server-api) 27 | - [Setup](#setup) 28 | - [Request Handling](#request-handling) 29 | - [Request Error Handling](#request-error-handling) 30 | - [Push Events](#push-events) 31 | - [Server Information](#server-information) 32 | - [Examples](#examples) 33 | - [Development](#development) 34 | 35 | ## Implementation 36 | 37 | A plugin's custom user interface has two main components: 38 | 39 | - [User Interface](#user-interface-api) - this is the HTML / CSS / JavaScript code the users interact with 40 | - [Server](#server-api) - this is an optional server side script that provides endpoints the UI can call 41 | 42 | ### Project Layout 43 | 44 | A custom UI should be published under a directory named `homebridge-ui`: 45 | 46 | - `homebridge-ui/public/index.html` - required - this is the plugin UI entry point. 47 | - `homebridge-ui/public/` - you can store any other assets (`.css`, `.js`, images etc.) in the public folder. 48 | - `homebridge-ui/server.js` - optional - this is the server side script containing API endpoints for your plugin UI. 49 | - `config.schema.json` - required - set `customUi` to `true` in the schema to enable custom UI. 50 | 51 | Basic structure example: 52 | 53 | ```bash 54 | homebridge-example-plugin/ 55 | ├── homebridge-ui 56 | │ ├── public 57 | │ │ └── index.html 58 | │ └── server.js 59 | ├── config.schema.json 60 | ├── package.json 61 | ``` 62 | 63 | You may customise the location of the `homebridge-ui` by setting the `customUiPath` property in the `config.schema.json`. For example: `"customUiPath": "./dist/homebridge-ui"`. 64 | 65 | ## User Interface API 66 | 67 | A plugin's custom user interface is displayed inside an iframe in the settings modal, in place of the schema-generated form. 68 | 69 | The user interface API is provided to the plugin's custom UI via the `window.homebridge` object. This is injected into the plugin's custom UI during render. 70 | 71 |

72 | 73 |

74 | 75 | Note: 76 | 77 | - Developers are free to use front end frameworks such as Angular, Vue, or React to create the plugin's custom user interface. 78 | - Developers should make use [Bootstrap 5](https://getbootstrap.com/docs) CSS classes, as these will automatically be styled and themed correctly. There is no need to include the boostrap css yourself, this will be injected by the Homebridge UI during render. 79 | - As the user interface is displayed in an isolated iframe, you can safely use any custom JavaScript and CSS. 80 | - The `index.html` file should not include ``, ``, or `` tags, as these are added by the Homebridge UI during the render process. 81 | - You may include external assets in your HTML. 82 | 83 | Example `index.html`: 84 | 85 | ```html 86 | 87 | 88 |
89 |
90 | 91 | 92 | Help text... 93 |
94 |
95 | 96 | 105 | ``` 106 | 107 | ### Config 108 | 109 | #### `homebridge.getPluginConfig` 110 | 111 | > `homebridge.getPluginConfig(): Promise;` 112 | 113 | Returns a promise that resolves an array of accessory or platform config blocks for the plugin. 114 | 115 | An empty array will be returned if the plugin is not currently configured. 116 | 117 | ```ts 118 | const pluginConfigBlocks = await homebridge.getPluginConfig() 119 | // [{ platform: 'ExamplePlatform', name: 'example' }] 120 | ``` 121 | 122 | #### `homebridge.updatePluginConfig` 123 | 124 | > `homebridge.updatePluginConfig(pluginConfig: PluginConfig[]): Promise;` 125 | 126 | Update the plugin config. 127 | 128 | - `pluginConfig`: A full array of platform and accessory config blocks. 129 | 130 | This should be called whenever a change to the config is made. 131 | 132 | This does not save the plugin config to disk. 133 | 134 | Existing blocks not included will be removed. 135 | 136 | ```ts 137 | const pluginConfig = [ 138 | { 139 | name: 'my light 1', 140 | accessory: 'ExampleAccessory' 141 | }, 142 | { 143 | name: 'my light 2', 144 | accessory: 'ExampleAccessory' 145 | } 146 | ] 147 | 148 | await homebridge.updatePluginConfig(pluginConfig) 149 | ``` 150 | 151 | #### `homebridge.savePluginConfig` 152 | 153 | > `homebridge.savePluginConfig(): Promise` 154 | 155 | Saves the plugin config changes to the Homebridge `config.json`. This is the equivalent of clicking the _Save_ button. 156 | 157 | This should be used sparingly, for example, after an access token is generated. 158 | 159 | You must call `await homebridge.updatePluginConfig()` first. 160 | 161 | ```ts 162 | // update config first! 163 | await homebridge.updatePluginConfig(pluginConfig) 164 | 165 | // save config 166 | await homebridge.savePluginConfig() 167 | ``` 168 | 169 | #### `homebridge.getPluginConfigSchema` 170 | 171 | > `homebridge.getPluginConfigSchema(): Promise;` 172 | 173 | Returns the plugin's config.schema.json. 174 | 175 | ```ts 176 | const schema = await homebridge.getPluginConfigSchema() 177 | ``` 178 | 179 | #### `homebridge.getCachedAccessories` 180 | 181 | > `homebridge.getCachedAccessories(): Promise;` 182 | 183 | Returns the cached accessories for the plugin 184 | 185 | ```ts 186 | const cachedAccessories = await homebridge.getCachedAccessories() 187 | ``` 188 | 189 | ### Environment 190 | 191 | #### `homebridge.i18nCurrentLang` 192 | 193 | > `homebridge.i18nCurrentLang(): Promise;` 194 | 195 | Return the current language the user interface is displayed in. Returns the i18n country code. 196 | 197 | #### `homebridge.userCurrentLightingMode` 198 | 199 | > `homebridge.userCurrentLightingMode(): Promise<'light' | 'dark'>;` 200 | 201 | Returns the lighting mode currently being used by the UI. 202 | 203 | 204 | ### Requests 205 | 206 | This allows the custom UI to make API requests to their `server.js` script. 207 | 208 | #### `homebridge.request` 209 | 210 | > `homebridge.request(path: string, body?: any): Promise` 211 | 212 | Make a request to the plugin's server side script. 213 | 214 | - `path`: the path handler on the server that the request should be sent to 215 | - `body`: an optional payload 216 | 217 | Returns a promise with the response from the server. 218 | 219 | User Interface Example: 220 | 221 | ```ts 222 | const response = await homebridge.request('/hello', { who: 'world' }) 223 | console.log(response) // the response from the server 224 | ``` 225 | 226 | The corresponding code in the `server.js` file would look like this: 227 | 228 | ```js 229 | // server side request handler 230 | this.onRequest('/hello', async (payload) => { 231 | console.log(payload) // the payload sent from the UI 232 | return { hello: 'user' } 233 | }) 234 | ``` 235 | 236 | ### Toast Notifications 237 | 238 | Toast notifications are the pop-up notifications displayed in the bottom right corner. A plugin's custom UI can generate custom notifications with custom content. 239 | 240 |

241 | 242 |

243 | 244 | #### `homebridge.toast.success` 245 | 246 | > `homebridge.toast.success(message: string, title?: string): void` 247 | 248 | Shows a green "success" notification. 249 | 250 | - `message`: the toast content 251 | - `title`: an optional title 252 | 253 | #### `homebridge.toast.error` 254 | 255 | > `homebridge.toast.error(message: string, title?: string): void` 256 | 257 | Shows a red "error" notification. 258 | 259 | - `message`: the toast content 260 | - `title`: an optional title 261 | 262 | #### `homebridge.toast.warning` 263 | 264 | > `homebridge.toast.warning(message: string, title?: string): void` 265 | 266 | Shows an amber "warning" notification. 267 | 268 | - `message`: the toast content 269 | - `title`: an optional title 270 | 271 | #### `homebridge.toast.info` 272 | 273 | > `homebridge.toast.info(message: string, title?: string): void` 274 | 275 | Shows a blue "info" notification. 276 | 277 | - `message`: the toast content 278 | - `title`: an optional title 279 | 280 | ### Modal 281 | 282 | #### `homebridge.closeSettings` 283 | 284 | > `homebridge.closeSettings(): void` 285 | 286 | Close the settings modal. 287 | 288 | This action does not save any config changes. 289 | 290 | ```ts 291 | homebridge.closeSettings() 292 | ``` 293 | 294 | #### `homebridge.showSpinner` 295 | 296 | > `homebridge.showSpinner(): void` 297 | 298 | Displays a spinner / loading overlay, preventing user input until cleared with `homebridge.hideSpinner`. 299 | 300 | ```ts 301 | // show the spinner overlay 302 | homebridge.showSpinner() 303 | 304 | // wait for the request to process 305 | await homebridge.request('/hello') 306 | 307 | // hide the spinner overlay 308 | homebridge.hideSpinner() 309 | ``` 310 | 311 | #### `homebridge.hideSpinner` 312 | 313 | > `homebridge.hideSpinner(): void` 314 | 315 | Hide the spinner / loading overlay. 316 | 317 | ```ts 318 | homebridge.hideSpinner() 319 | ``` 320 | 321 | #### `homebridge.disableSaveButton` 322 | 323 | > `homebridge.disableSaveButton(): void` 324 | 325 | Disables the save button in the settings modal. 326 | 327 | ```ts 328 | homebridge.disableSaveButton() 329 | ``` 330 | 331 | #### `homebridge.enableSaveButton` 332 | 333 | > `homebridge.enableSaveButton(): void` 334 | 335 | Enables the save button in the settings modal. 336 | 337 | ```ts 338 | homebridge.enableSaveButton() 339 | ``` 340 | 341 | ### Forms 342 | 343 | The custom user interface allows you to create two types of forms: 344 | 345 | 1. A form based on your plugin's `config.schema.json` file 346 | - User input is automatically mapped to the plugin config object 347 | - You can listen for change events from your custom user interface 348 | - The schema must contain all config options 349 | 2. A standalone form 350 | - Not linked to your `config.schema.json` form in any way 351 | - You must listen for change events, process the event, and update the plugin config 352 | - The form does not need to include all config options 353 | 354 | Developers are also able to create their own forms using HTML. 355 | 356 | #### `homebridge.showSchemaForm` 357 | 358 | > `homebridge.showSchemaForm(): void` 359 | 360 | Show the schema-generated form below the custom user interface. 361 | This feature only works for platform plugins that have set `singular` = `true` in their config.schema.json file. 362 | 363 | ```ts 364 | homebridge.showSchemaForm() 365 | ``` 366 | 367 | When enabling the schema form, you should listen for the `configChanged` event to keep your config in sync. This event is triggered whenever the user makes a change in the schema-generated form (250ms debounce). 368 | 369 | ```ts 370 | window.homebridge.addEventListener('configChanged', (event: MessageEvent) => { 371 | console.log('Updated config:', event.data) 372 | }) 373 | ``` 374 | 375 | #### `homebridge.hideSchemaForm` 376 | 377 | > `homebridge.hideSchemaForm(): void` 378 | 379 | Hides the schema-generated form. 380 | 381 | ```ts 382 | homebridge.hideSchemaForm() 383 | ``` 384 | 385 | #### `homebridge.createForm` 386 | 387 | > `homebridge.createForm(schema: FormSchema, data: any, submitButton?: string, cancelButton?: string): IHomebridgeUiFormHelper;` 388 | 389 | Create a new standalone form. You may pass in an arbitrary schema using the same options as the [config.schema.json](https://developers.homebridge.io/#/config-schema). 390 | 391 | Only one standalone form can be displayed at a time. The main config-schema based form cannot be shown while a standalone form is being displayed. 392 | 393 | - `schema`: The [form schema object](https://developers.homebridge.io/#/config-schema), may also contain layout metadata 394 | - `data`: The initial form data 395 | - `submitButton`: String. Optional label for a submit button, if not provided, no submit button will be displayed 396 | - `cancelButton`: String. Optional label for a cancel button, if not provided, no cancel button will be displayed 397 | 398 | Example: 399 | 400 | ```ts 401 | // create the form 402 | const myForm = homebridge.createForm( 403 | { 404 | schema: { 405 | type: 'object', 406 | properties: { 407 | name: { 408 | title: 'Name', 409 | type: 'string', 410 | required: true, 411 | } 412 | } 413 | }, 414 | layout: null, 415 | form: null, 416 | }, 417 | { 418 | name: 'initial name value' 419 | } 420 | ) 421 | 422 | // watch for change events 423 | myForm.onChange((change) => { 424 | console.log(change) 425 | }) 426 | 427 | // watch for submit button click events 428 | myForm.onSubmit((form) => { 429 | console.log(form) 430 | }) 431 | 432 | // watch for cancel button click events 433 | myForm.onCancel((form) => { 434 | console.log(form) 435 | }) 436 | 437 | // stop listening to change events and hide the form 438 | myForm.end() 439 | ``` 440 | 441 | ### Events 442 | 443 | The `homebridge` object is an [EventTarget](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget), this allows you to use the browsers built in [addEventListener](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener) and [removeEventListener](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener) functions to subscribe and unsubscribe from events. 444 | 445 | #### Ready Event 446 | 447 | Called when the Homebridge UI has completed rendering the plugin's custom UI. 448 | 449 | ```ts 450 | homebridge.addEventListener('ready', () => { 451 | // do something with event 452 | }) 453 | ``` 454 | 455 | #### Custom Events 456 | 457 | Custom events can be pushed from the plugin's `server.js` script. 458 | 459 | UI Example: 460 | 461 | ```ts 462 | homebridge.addEventListener('my-event', (event) => { 463 | console.log(event.data) // the event payload from the server 464 | }) 465 | ``` 466 | 467 | The corresponding code in the `server.js` file would look like this: 468 | 469 | ```ts 470 | this.pushEvent('my-event', { some: 'data' }) 471 | ``` 472 | 473 | ### Plugin / Server Information 474 | 475 | #### `homebridge.plugin` 476 | 477 | > `homebridge.plugin` 478 | 479 | Is an object that contains plugin metadata. 480 | 481 | ```ts 482 | { 483 | name: string; 484 | description: string; 485 | installedVersion: string; 486 | latestVersion: string; 487 | verifiedPlugin: boolean; 488 | updateAvailable: boolean; 489 | publicPackage: boolean; 490 | links: { 491 | npm: string; 492 | homepage?: string; 493 | } 494 | } 495 | ``` 496 | 497 | #### homebridge.serverEnv 498 | 499 | > `homebridge.serverEnv` 500 | 501 | Is an object containing some server metadata 502 | 503 | ```ts 504 | { 505 | env: { 506 | platform: string // darwin, win32, linux, freebsd etc. 507 | nodeVersion: string // Node.js version 508 | } 509 | } 510 | ``` 511 | 512 | ## Server API 513 | 514 | To provide server API endpoints that can be called from the custom UI, a plugin must place a `server.js` file in the `homebridge-ui` directory. 515 | 516 | You will need to include the `@homebridge/plugin-ui-utils` library as a prod dependency: 517 | 518 | ``` 519 | npm install --save @homebridge/plugin-ui-utils 520 | ``` 521 | 522 | Note: 523 | 524 | - This `server.js` script will be spawned as a child process when the plugin's settings modal is opened, and is terminated when the settings modal is closed. 525 | - The `server.js` script must create a new instance of a class that extends `HomebridgePluginUiServer` from the `@homebridge/plugin-ui-utils` library. 526 | - This file will be spawned as a child process when the plugin's settings modal is opened, and is terminated when the settings modal is closed. 527 | - The server side script must extend the class provided by the `@homebridge/plugin-ui-utils` library. 528 | 529 | Example `server.js`: 530 | 531 | ```js 532 | import { HomebridgePluginUiServer } from '@homebridge/plugin-ui-utils' 533 | 534 | // your class MUST extend the HomebridgePluginUiServer 535 | class UiServer extends HomebridgePluginUiServer { 536 | constructor () { 537 | // super must be called first 538 | super() 539 | 540 | // Example: create api endpoint request handlers (example only) 541 | this.onRequest('/hello', this.handleHelloRequest.bind(this)) 542 | 543 | // this.ready() must be called to let the UI know you are ready to accept api calls 544 | this.ready() 545 | } 546 | 547 | /** 548 | * Example only. 549 | * Handle requests made from the UI to the `/hello` endpoint. 550 | */ 551 | async handleHelloRequest(payload) { 552 | return { hello: 'world' } 553 | } 554 | } 555 | 556 | // start the instance of the class 557 | (() => { 558 | return new UiServer; 559 | })(); 560 | ``` 561 | 562 | ### Setup 563 | 564 | #### `this.ready` 565 | 566 | > `this.ready(): void` 567 | 568 | Let the UI know the server is ready to accept requests. 569 | 570 | ```ts 571 | this.ready() 572 | ``` 573 | 574 | ### Request Handling 575 | 576 | #### `this.onRequest` 577 | 578 | > `this.onRequest(path: string, fn: RequestHandler)` 579 | 580 | Handle requests sent from the UI to the given path. 581 | 582 | - `path`: the request path name 583 | - `fn`: a function to handle the incoming requests 584 | 585 | The value returned/resolved from the request handler function will be sent back to the UI as the request response. 586 | 587 | Example creating a request handler on the server: 588 | 589 | ```ts 590 | // server side code 591 | this.onRequest('/hello', async (payload) => { 592 | console.log(payload) // the payload sent from the UI 593 | return { hello: 'user' } 594 | }) 595 | ``` 596 | 597 | The corresponding call in the UI to send requests to this endpoint: 598 | 599 | ```ts 600 | // ui code 601 | const response = await homebridge.request('/hello', { who: 'world' }) 602 | console.log(response) // the response from the server 603 | ``` 604 | 605 | ### Request Error Handling 606 | 607 | If you need to throw an error during your request, you should throw an instance of `RequestError` instead of a normal `Error`: 608 | 609 | Example: 610 | 611 | ```ts 612 | // server side code 613 | import { RequestError } from '@homebridge/plugin-ui-utils' 614 | 615 | this.onRequest('/hello', async (payload) => { 616 | // something went wrong, throw a RequestError: 617 | throw new RequestError('Something went wrong!', { status: 404 }) 618 | }) 619 | ``` 620 | 621 | You can then catch this in the UI: 622 | 623 | ```ts 624 | try { 625 | await homebridge.request('/hello', { who: 'world' }) 626 | } catch (e) { 627 | console.log(e.message) // 'Something went wrong!' 628 | console.log(e.error) // { status: 404 } 629 | } 630 | ``` 631 | 632 | Uncaught errors in event handlers, or errors thrown using `new Error` will still result in the waiting promise in the UI being rejected, however the error stack trace will also be shown in the Homebridge logs which should be avoided. 633 | 634 | ### Push Events 635 | 636 | #### `this.pushEvent` 637 | 638 | > `this.pushEvent(event: string, data: any)` 639 | 640 | Push events allow you to send data to the UI, without needed the UI to request it first. 641 | 642 | - `event`: a string to describe the event type 643 | - `data`: any data to send as an event payload to the UI. 644 | 645 | Example pushing an event payload to the UI: 646 | 647 | ```ts 648 | this.pushEvent('my-event', { some: 'data' }) 649 | ``` 650 | 651 | The corresponding code to watch for the event in the UI: 652 | 653 | ```ts 654 | homebridge.addEventListener('my-event', (event) => { 655 | console.log(event.data) // the event payload from the server 656 | }) 657 | ``` 658 | 659 | ### Server Information 660 | 661 | #### `this.homebridgeStoragePath` 662 | 663 | > `this.homebridgeStoragePath: string` 664 | 665 | Returns the Homebridge instance's current storage path. 666 | 667 | ```ts 668 | const storagePath = this.homebridgeStoragePath 669 | ``` 670 | 671 | #### `this.homebridgeConfigPath` 672 | 673 | > `this.homebridgeConfigPath: string` 674 | 675 | Returns the path to the Homebridge `config.json` file: 676 | 677 | ```ts 678 | const configPath = this.homebridgeConfigPath 679 | ``` 680 | 681 | #### `this.homebridgeUiVersion` 682 | 683 | > `this.homebridgeUiVersion: string` 684 | 685 | Returns the version of the Homebridge UI: 686 | 687 | ```ts 688 | const uiVersion = this.homebridgeUiVersion 689 | ``` 690 | 691 | ## Examples 692 | 693 | - [Basic Example](./examples/basic-ui-server) - demos a minimal custom user interface, interacting with server side scripts, updating the plugin config, and using toast notifications. 694 | - [Push Events](./examples/push-events) - demos how to send push events from the server, and listen for them in the custom user interface. 695 | 696 | A full list of plugins that have implemented the custom user interface can be found [here](https://www.npmjs.com/package/@homebridge/plugin-ui-utils?activeTab=dependents). 697 | 698 | ##### homebridge-mercedesme 699 | 700 | The [homebridge-mercedesme](https://github.com/SeydX/homebridge-mercedesme) plugin by [@SeydX](https://github.com/SeydX) allows users to pair their vehicle using a custom user interface: 701 | 702 |

703 | 704 |

705 | 706 | ##### homebridge-bravia-tvos 707 | 708 | The [homebridge-bravia-tvos](https://github.com/SeydX/homebridge-bravia-tvos) plugin by [@SeydX](https://github.com/SeydX) allows users to pair and dynamically configure a user's TV using a custom user interface: 709 | 710 |

711 | 712 |

713 | 714 | ##### homebridge-electra-smart 715 | 716 | The [homebridge-electra-smart](https://github.com/nitaybz/homebridge-electra-smart) plugin by [nitaybz](https://github.com/nitaybz) allows users to request an OTP and enter it in exchange for an authentication token: 717 | 718 |

719 | 720 |

721 | 722 | ## Development 723 | 724 | For hints and tips on how to develop your custom user interface, see [DEVELOPMENT.md](./DEVELOPMENT.md). 725 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | 3 | export default antfu({ 4 | ignores: ['dist', 'README.md', 'DEVELOPMENT.md'], 5 | rules: { 6 | 'new-cap': 'off', 7 | 'import/extensions': ['error', 'ignorePackages'], 8 | 'import/order': 'off', 9 | 'jsdoc/check-alignment': 'error', 10 | 'jsdoc/check-line-alignment': 'error', 11 | 'no-undef': 'error', 12 | 'perfectionist/sort-exports': 'error', 13 | 'perfectionist/sort-named-exports': 'error', 14 | 'perfectionist/sort-imports': [ 15 | 'error', 16 | { 17 | groups: [ 18 | 'type', 19 | 'internal-type', 20 | ['parent-type', 'sibling-type', 'index-type'], 21 | 'builtin', 22 | 'external', 23 | 'internal', 24 | ['parent', 'sibling', 'index'], 25 | 'side-effect', 26 | 'object', 27 | 'unknown', 28 | ], 29 | order: 'asc', 30 | type: 'natural', 31 | newlinesBetween: 'always', 32 | }, 33 | ], 34 | 'quotes': ['error', 'single'], 35 | 'sort-imports': 'off', 36 | 'style/brace-style': ['error', '1tbs'], 37 | 'style/quote-props': ['error', 'consistent-as-needed'], 38 | 'test/no-only-tests': 'error', 39 | 'unicorn/no-useless-spread': 'error', 40 | 'unused-imports/no-unused-vars': ['error', { caughtErrors: 'none' }], 41 | }, 42 | typescript: true, 43 | formatters: { 44 | markdown: true, 45 | }, 46 | }) 47 | -------------------------------------------------------------------------------- /examples/basic-ui-server/config.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "pluginAlias": "HomebridgeBasicExample", 3 | "pluginType": "platform", 4 | "singular": true, 5 | "customUi": true, 6 | "schema": { 7 | "properties": { 8 | "token": { 9 | "title": "Token", 10 | "type": "string", 11 | "required": true 12 | }, 13 | "username": { 14 | "title": "Username", 15 | "type": "string", 16 | "required": true 17 | } 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /examples/basic-ui-server/homebridge-ui/public/index.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 6 |
7 |
8 | 9 | 10 |
11 |
12 | 13 |
14 |
15 |
16 | 17 | -------------------------------------------------------------------------------- /examples/basic-ui-server/homebridge-ui/server.js: -------------------------------------------------------------------------------- 1 | import { createHash } from 'node:crypto' 2 | 3 | import { HomebridgePluginUiServer, RequestError } from '@homebridge/plugin-ui-utils' 4 | 5 | class PluginUiServer extends HomebridgePluginUiServer { 6 | constructor() { 7 | // super() MUST be called first 8 | super() 9 | 10 | // handle request for the /token route 11 | this.onRequest('/token', this.generateToken.bind(this)) 12 | 13 | // this MUST be called when you are ready to accept requests 14 | this.ready() 15 | } 16 | 17 | async generateToken(payload) { 18 | // eslint-disable-next-line no-console 19 | console.log('Username:', payload.username) 20 | 21 | // sleep for 1 second, just to demo async works 22 | await new Promise(resolve => setTimeout(resolve, 1000)) 23 | 24 | try { 25 | // generate a sha256 from the username and use that as a fake token 26 | const hashedUsername = createHash('sha256').update(payload.username).digest().toString('hex') 27 | 28 | // return data to the ui 29 | return { 30 | token: hashedUsername, 31 | } 32 | } catch (e) { 33 | throw new RequestError('Failed to Generate Token', { message: e.message }) 34 | } 35 | } 36 | } 37 | 38 | (() => new PluginUiServer())() 39 | -------------------------------------------------------------------------------- /examples/basic-ui-server/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "homebridge-ui-basic-example", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "name": "homebridge-ui-basic-example", 8 | "dependencies": { 9 | "@homebridge/plugin-ui-utils": "1.0.2" 10 | }, 11 | "engines": { 12 | "homebridge": "^1.6.0", 13 | "node": "^18 || ^20" 14 | } 15 | }, 16 | "node_modules/@homebridge/plugin-ui-utils": { 17 | "version": "1.0.2", 18 | "resolved": "https://registry.npmjs.org/@homebridge/plugin-ui-utils/-/plugin-ui-utils-1.0.2.tgz", 19 | "integrity": "sha512-QWjk3TvqJTkInVb0ihlVotLfqbtqycnZ+rWYN7qTLomxZcPyU5cuMGvBhKYx+AaaPS+yGd0vhGdJDj/UlKJp/Q==" 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/basic-ui-server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "homebridge-ui-basic-example", 3 | "type": "module", 4 | "private": true, 5 | "keywords": [ 6 | "homebridge-plugin" 7 | ], 8 | "engines": { 9 | "node": "^18 || ^20 || ^22", 10 | "homebridge": "^1.8.0" 11 | }, 12 | "dependencies": { 13 | "@homebridge/plugin-ui-utils": "2.0.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /examples/push-events/config.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "pluginAlias": "HomebridgePushEventsExample", 3 | "pluginType": "platform", 4 | "singular": true, 5 | "customUi": true, 6 | "schema": { 7 | "properties": { 8 | "token": { 9 | "title": "Token", 10 | "type": "string", 11 | "required": true 12 | }, 13 | "username": { 14 | "title": "Username", 15 | "type": "string", 16 | "required": true 17 | } 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /examples/push-events/homebridge-ui/public/index.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Server Time

4 |

Please wait...

5 |
6 |
7 | 8 | -------------------------------------------------------------------------------- /examples/push-events/homebridge-ui/server.js: -------------------------------------------------------------------------------- 1 | import { HomebridgePluginUiServer } from '@homebridge/plugin-ui-utils' 2 | 3 | class PluginUiServer extends HomebridgePluginUiServer { 4 | constructor() { 5 | // super() MUST be called first 6 | super() 7 | 8 | // this MUST be called when you are ready to accept requests 9 | this.ready() 10 | 11 | // push event example: push the current server time every second 12 | setInterval(() => { 13 | this.pushEvent('server-time-event', { 14 | time: new Date().toLocaleString(), 15 | }) 16 | }, 1000) 17 | } 18 | } 19 | 20 | (() => new PluginUiServer())() 21 | -------------------------------------------------------------------------------- /examples/push-events/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "homebridge-ui-push-events-example", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "name": "homebridge-ui-push-events-example", 8 | "dependencies": { 9 | "@homebridge/plugin-ui-utils": "1.0.2" 10 | }, 11 | "engines": { 12 | "homebridge": "^1.6.0", 13 | "node": "^18 || ^20" 14 | } 15 | }, 16 | "node_modules/@homebridge/plugin-ui-utils": { 17 | "version": "1.0.2", 18 | "resolved": "https://registry.npmjs.org/@homebridge/plugin-ui-utils/-/plugin-ui-utils-1.0.2.tgz", 19 | "integrity": "sha512-QWjk3TvqJTkInVb0ihlVotLfqbtqycnZ+rWYN7qTLomxZcPyU5cuMGvBhKYx+AaaPS+yGd0vhGdJDj/UlKJp/Q==" 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/push-events/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "homebridge-ui-push-events-example", 3 | "type": "module", 4 | "private": true, 5 | "keywords": [ 6 | "homebridge-plugin" 7 | ], 8 | "engines": { 9 | "node": "^18 || ^20 || ^22", 10 | "homebridge": "^1.8.0" 11 | }, 12 | "dependencies": { 13 | "@homebridge/plugin-ui-utils": "2.0.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@homebridge/plugin-ui-utils", 3 | "type": "module", 4 | "version": "2.1.0", 5 | "description": "A tool to help plugins provide custom UI screens in the Homebridge UI.", 6 | "author": { 7 | "name": "oznu", 8 | "email": "dev@oz.nu" 9 | }, 10 | "contributors": [ 11 | { 12 | "name": "homebridge", 13 | "url": "https://github.com/homebridge" 14 | } 15 | ], 16 | "license": "MIT", 17 | "homepage": "https://github.com/homebridge/plugin-ui-utils#readme", 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/homebridge/plugin-ui-utils.git" 21 | }, 22 | "bugs": { 23 | "url": "https://github.com/homebridge/plugin-ui-utils/issues" 24 | }, 25 | "keywords": [ 26 | "homebridge", 27 | "homebridge-ui" 28 | ], 29 | "exports": { 30 | ".": { 31 | "types": "./dist/index.d.ts", 32 | "default": "./dist/index.js" 33 | }, 34 | "./ui.interface": { 35 | "types": "./dist/ui.interface.d.ts", 36 | "default": "./dist/ui.interface.js" 37 | }, 38 | "./package.json": "./package.json" 39 | }, 40 | "types": "dist/index.d.ts", 41 | "files": [ 42 | "LICENSE", 43 | "README.md", 44 | "dist" 45 | ], 46 | "scripts": { 47 | "check": "npm install && npm outdated", 48 | "build": "rimraf ./dist && tsc --project tsconfig.json && tsc --project tsconfig.ui.json", 49 | "lint": "eslint .", 50 | "lint:fix": "npm run lint -- --fix", 51 | "prepublishOnly": "npm run lint && npm run build", 52 | "test": "echo \"No test script specified\" && exit 0", 53 | "test-coverage": "echo \"No test-coverage script specified\" && exit 0" 54 | }, 55 | "devDependencies": { 56 | "@antfu/eslint-config": "^4.13.2", 57 | "@types/node": "^22.15.29", 58 | "eslint-plugin-format": "^1.0.1", 59 | "rimraf": "^6.0.1", 60 | "ts-node": "^10.9.2", 61 | "typescript": "^5.8.3" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { HomebridgePluginUiServer, RequestError } from './server.js' 2 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process' 2 | 3 | /** 4 | * Homebridge Custom Plugin UI Base Class 5 | * This provides the api to facilitate two-way communication between a plugin 6 | * custom UI HTML code and the server. 7 | * 8 | * This is a base class and is intended to be extended. 9 | * 10 | * @example 11 | * ```ts 12 | * class MyPluginUiServer extends HomebridgePluginUiServer { 13 | * constructor() { 14 | * // this MUST be called first 15 | * super(); 16 | * 17 | * // this MUST be called to let the UI know your script is ready for requests 18 | * this.ready(); 19 | * 20 | * // example handling requests 21 | * this.onRequest('/hello', (payload) => { 22 | * return { hello: 'world' }; 23 | * }); 24 | * } 25 | * } 26 | * 27 | * // start the class 28 | * (() => { 29 | * return new MyPluginUiServer(); 30 | * })(); 31 | * ``` 32 | */ 33 | export class HomebridgePluginUiServer { 34 | private handlers: Record = {} 35 | 36 | constructor() { 37 | if (!process.send) { 38 | console.error('This script can only run as a child process.') 39 | process.exit(1) 40 | } 41 | 42 | process.addListener('message', (request: any) => { 43 | switch (request.action) { 44 | case 'request': { 45 | this.processRequest(request) 46 | } 47 | } 48 | }) 49 | } 50 | 51 | get homebridgeStoragePath() { 52 | return process.env.HOMEBRIDGE_STORAGE_PATH 53 | } 54 | 55 | get homebridgeConfigPath() { 56 | return process.env.HOMEBRIDGE_CONFIG_PATH 57 | } 58 | 59 | get homebridgeUiVersion() { 60 | return process.env.HOMEBRIDGE_UI_VERSION 61 | } 62 | 63 | private sendResponse(request: any, data: any, success = true) { 64 | if (!process.send) { 65 | return 66 | } 67 | 68 | process.send({ 69 | action: 'response', 70 | payload: { 71 | requestId: request.requestId, 72 | success, 73 | data, 74 | }, 75 | }) 76 | } 77 | 78 | private async processRequest(request: { path: string, body: any }) { 79 | if (this.handlers[request.path]) { 80 | try { 81 | // eslint-disable-next-line no-console 82 | console.log('Incoming Request:', request.path) 83 | const resp = await this.handlers[request.path](request.body || {}) 84 | return this.sendResponse(request, resp, true) 85 | } catch (e) { 86 | if (e instanceof RequestError) { 87 | return this.sendResponse(request, { message: e.message, error: e.requestError }, false) 88 | } else { 89 | console.error(e) 90 | return this.sendResponse(request, { message: (e as Error).message }, false) 91 | } 92 | } 93 | } else { 94 | console.error('No Registered Handler:', request.path) 95 | return this.sendResponse(request, { message: 'Not Found', path: request.path }, false) 96 | } 97 | } 98 | 99 | /** 100 | * Let the server and UI know you are ready to receive requests. 101 | * This method must be called when you are ready to process requests! 102 | * @example 103 | * ```ts 104 | * this.ready(); 105 | * ``` 106 | */ 107 | public ready(): void { 108 | if (!process.send) { 109 | return 110 | } 111 | 112 | process.send({ 113 | action: 'ready', 114 | payload: { 115 | server: true, 116 | }, 117 | }) 118 | } 119 | 120 | /** 121 | * Register a new request handler for a given route. 122 | * @param path the request route name 123 | * @param fn the function to handle the request and provide a response 124 | * 125 | * @example 126 | * ```ts 127 | * this.onRequest('/hello', async (payload) => { 128 | * return {hello: 'user'}; 129 | * }); 130 | * ``` 131 | * 132 | * You can then make requests to this endpoint from the client / ui using `homebridge.request`: 133 | * @example 134 | * ```ts 135 | * homebridge.request('/hello', {some: 'payload data'}); 136 | * ``` 137 | * 138 | */ 139 | public onRequest(path: string, fn: RequestHandler) { 140 | this.handlers[path] = fn 141 | } 142 | 143 | /** 144 | * Push an event or stream data to the UI. 145 | * @param event the event name, the plugin UI can listen for this event 146 | * @param data the data to send 147 | * 148 | * @example 149 | * ```ts 150 | * this.pushEvent('my-event', {some: 'data'}); 151 | * ``` 152 | * 153 | * In the client / ui, you would then listen to this event using `homebridge.addEventListener`: 154 | * 155 | * @example 156 | * ```ts 157 | * homebridge.addEventListener('my-event', (event) => { 158 | * // do something with the event 159 | * }); 160 | * ``` 161 | */ 162 | public pushEvent(event: string, data: any) { 163 | if (!process.send) { 164 | return 165 | } 166 | 167 | process.send({ 168 | action: 'stream', 169 | payload: { 170 | event, 171 | data, 172 | }, 173 | }) 174 | } 175 | } 176 | 177 | export class RequestError extends Error { 178 | public requestError: any 179 | 180 | constructor(message: string, requestError: any) { 181 | super(message) 182 | Object.setPrototypeOf(this, RequestError.prototype) 183 | 184 | this.requestError = requestError 185 | } 186 | } 187 | 188 | type RequestResponse = string | number | Record | Array 189 | type RequestHandler = (arg: any) => Promise | RequestResponse 190 | 191 | setInterval(() => { 192 | if (!process.connected) { 193 | process.kill(process.pid, 'SIGTERM') 194 | } 195 | }, 10000) 196 | 197 | process.on('disconnect', () => { 198 | process.kill(process.pid, 'SIGTERM') 199 | }) 200 | -------------------------------------------------------------------------------- /src/ui.interface.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Window { 3 | homebridge: IHomebridgePluginUi 4 | } 5 | } 6 | 7 | export interface PluginSchema extends Record { 8 | pluginAlias: string 9 | pluginType: string 10 | singular?: boolean 11 | customUi?: boolean 12 | headerDisplay?: string 13 | footerDisplay?: string 14 | schema?: Record 15 | layout?: Record[] 16 | form?: Record[] 17 | } 18 | 19 | export interface PluginFormSchema { 20 | schema: Record 21 | layout?: Record[] | null 22 | form?: Record[] | null 23 | } 24 | 25 | export interface PluginMetadata { 26 | name: string 27 | displayName?: string 28 | description: string 29 | verifiedPlugin: boolean 30 | installedVersion: string 31 | latestVersion: string | null 32 | updateAvailable: boolean 33 | publicPackage: boolean 34 | globalInstall: boolean 35 | settingsSchema: boolean 36 | installPath: string 37 | links: Record[] 38 | funding?: Record[] 39 | } 40 | 41 | export interface ServerEnvMetadata { 42 | theme: string 43 | serverTimestamp: string 44 | formAuth: boolean | 'none' 45 | env: { 46 | ableToConfigureSelf: boolean 47 | dockerOfflineUpdate: boolean 48 | enableAccessories: boolean 49 | enableTerminalAccess: boolean 50 | homebridgeInstanceName: string 51 | nodeVersion: string 52 | packageName: string 53 | packageVersion: string 54 | platform: string 55 | runningInDocker: boolean 56 | runningInLinux: boolean 57 | serviceMode: boolean 58 | temperatureUnits: string 59 | lang: string | null 60 | instanceId: string 61 | } 62 | } 63 | 64 | export interface CachedAccessory { 65 | plugin: string 66 | platform: string 67 | context: Record 68 | displayName: string 69 | UUID: string 70 | category: string 71 | services: any[] 72 | } 73 | 74 | export declare type PluginConfig = Record 75 | 76 | export declare class IHomebridgePluginUi extends EventTarget { 77 | /** 78 | * Send a popup toast notification to the UI. 79 | */ 80 | public toast: IHomebridgeUiToastHelper 81 | 82 | /** 83 | * An object containing information about the current plugin. 84 | */ 85 | public plugin: PluginMetadata 86 | 87 | /** 88 | * An object containing information about the server. 89 | */ 90 | public serverEnv: ServerEnvMetadata 91 | 92 | /** 93 | * Tell the UI to adjust the height of the iframe container to the same as your document body 94 | */ 95 | public fixScrollHeight(): void 96 | 97 | /** 98 | * Close the Plugin Settings modal. 99 | * This action does not save any config changes. 100 | */ 101 | public closeSettings(): void 102 | 103 | /** 104 | * Show a loading spinner overlay. 105 | * Prevents user input until cleared with `homebridge.hideSpinner()` 106 | * 107 | * @example 108 | * ```ts 109 | * homebridge.showSpinner() 110 | * ``` 111 | */ 112 | public showSpinner(): void 113 | 114 | /** 115 | * Hide the loading spinner overlay. 116 | * 117 | * @example 118 | * ```ts 119 | * homebridge.hideSpinner() 120 | * ``` 121 | */ 122 | public hideSpinner(): void 123 | 124 | /** 125 | * Disable the save button in the UI. 126 | * 127 | * @example 128 | * ```ts 129 | * homebridge.disableSaveButton() 130 | * ``` 131 | */ 132 | public disableSaveButton(): void 133 | 134 | /** 135 | * Enable the save button in the UI. 136 | * 137 | * @example 138 | * ```ts 139 | * homebridge.enableSaveButton() 140 | * ``` 141 | */ 142 | public enableSaveButton(): void 143 | 144 | /** 145 | * Show the schema-generated form below the custom UI. 146 | * This only works for platform plugins that have set `singular` = `true` in their config.schema.json file. 147 | * 148 | * @example 149 | * ```ts 150 | * homebridge.showSchemaForm() 151 | * ``` 152 | */ 153 | public showSchemaForm(): void 154 | 155 | /** 156 | * Hides the schema-generated form. 157 | * 158 | * @example 159 | * ```ts 160 | * this.hideSchemaForm() 161 | * ``` 162 | */ 163 | public hideSchemaForm(): void 164 | 165 | /** 166 | * Create a standalone form using a generic schema. 167 | * This is not linked to the main config schema model, and you must listen for changes yourself. 168 | * 169 | * @param schema the schema used to generate the standalone form. See [schema guide](https://developers.homebridge.io/#/config-schema). 170 | * @param data the initial form data 171 | * 172 | * @param submitButton 173 | * @param cancelButton 174 | * @example 175 | * ```ts 176 | * const myForm = homebridge.createForm( 177 | * { 178 | * schema: { 179 | * type: 'object', 180 | * properties: { 181 | * name: { 182 | * title: 'Name', 183 | * type: string, 184 | * required: true, 185 | * } 186 | * } 187 | * }, 188 | * layout: null, 189 | * form: null, 190 | * }, 191 | * { 192 | * name: 'initial name value' 193 | * } 194 | * ); 195 | * 196 | * // listen for input changes 197 | * myForm.onChange((change) => { 198 | * console.log(change); 199 | * }); 200 | * 201 | * // stop listening / hide the form 202 | * myForm.end() 203 | * ``` 204 | */ 205 | public createForm(schema: PluginFormSchema, data: any, submitButton?: string, cancelButton?: string): IHomebridgeUiFormHelper 206 | 207 | /** 208 | * Removes the form. 209 | */ 210 | public endForm(): void 211 | 212 | /** 213 | * Get the current config for the plugin. 214 | * @returns an array of platforms or accessory config blocks. 215 | * @returns an empty array if the plugin has no current config. 216 | * 217 | * @example 218 | * ```ts 219 | * const pluginConfigBlocks = await homebridge.getPluginConfig() 220 | * ``` 221 | */ 222 | public getPluginConfig(): Promise 223 | 224 | /** 225 | * Update the plugin config. 226 | * This should be called whenever a change to the config is made. 227 | * This method does not save the changes to the config.json file. 228 | * Existing blocks not included will be removed. 229 | * 230 | * @example 231 | * ```ts 232 | * await homebridge.updatePluginConfig( 233 | * [ 234 | * { 235 | * "name": "my light", 236 | * "platform": "example_platform" 237 | * } 238 | * ] 239 | * ); 240 | * ``` 241 | */ 242 | public updatePluginConfig(pluginConfig: PluginConfig[]): Promise 243 | 244 | /** 245 | * Save the plugin config. 246 | * You must call `homebridge.updatePluginConfig` first. 247 | * 248 | * @example 249 | * ```ts 250 | * await homebridge.savePluginConfig() 251 | * ``` 252 | */ 253 | public savePluginConfig(): Promise 254 | 255 | /** 256 | * Returns the plugin's config.schema.json 257 | * 258 | * @example 259 | * ```ts 260 | * const schema = await homebridge.getPluginConfigSchema() 261 | * ``` 262 | */ 263 | public getPluginConfigSchema(): Promise 264 | 265 | /** 266 | * Return an array of cached accessories for your plugin. 267 | */ 268 | public getCachedAccessories(): Promise 269 | 270 | /** 271 | * Make a request to the plugins server side script 272 | * @param path - the path handler on the server that the request should be sent to 273 | * @param body - an optional payload 274 | * 275 | * @example 276 | * ```ts 277 | * 278 | * const response = await homebridge.request('/hello', { who: 'world' }); 279 | * console.log(response); // the response from the server 280 | * ``` 281 | * 282 | * The server side component would handle this using `this.onRequest`. 283 | * 284 | * @example 285 | * ```ts 286 | * this.onRequest('/hello', async (payload) => { 287 | * return {hello: 'user'}; 288 | * }); 289 | * ``` 290 | */ 291 | public request(path: string, body?: any): Promise 292 | 293 | /** 294 | * Return the current language the user interface is displayed in. 295 | * Returns the i18n country code. 296 | */ 297 | public i18nCurrentLang(): Promise 298 | 299 | /** 300 | * Returns the full translation object for the current language. 301 | */ 302 | public i18nGetTranslation(): Promise> 303 | 304 | /** 305 | * Returns the lighting mode currently being used by the UI. 306 | */ 307 | public userCurrentLightingMode(): Promise<'light' | 'dark'> 308 | } 309 | 310 | export declare class IHomebridgeUiToastHelper { 311 | /** 312 | * Trigger a success toast notification in the UI 313 | * @param message 314 | * @param title - optional title 315 | */ 316 | public success(message: string, title?: string): void 317 | 318 | /** 319 | * Trigger an error toast notification in the UI 320 | * @param message 321 | * @param title - optional title 322 | */ 323 | public error(message: string, title?: string): void 324 | 325 | /** 326 | * Trigger a warning toast notification in the UI 327 | * @param message 328 | * @param title - optional title 329 | */ 330 | public warning(message: string, title?: string): void 331 | 332 | /** 333 | * Trigger an info toast notification in the UI 334 | * @param message 335 | * @param title - optional title 336 | */ 337 | public info(message: string, title?: string): void 338 | } 339 | 340 | export declare class IHomebridgeUiFormHelper { 341 | constructor( 342 | parent: IHomebridgePluginUi, 343 | schema: PluginFormSchema, 344 | data: any, 345 | submitButton: string, 346 | cancelButton: string, 347 | ) 348 | 349 | /** 350 | * Hide the form and stop listening to events 351 | */ 352 | public end(): void 353 | 354 | /** 355 | * Listen to input / change events emitted by the standalone form 356 | * @param fn 357 | */ 358 | public onChange(fn: (change: Record) => any): void 359 | 360 | /** 361 | * Listen submit button form events 362 | * @param fn 363 | */ 364 | public onSubmit(fn: (change: Record) => any): void 365 | 366 | /** 367 | * Listen cancel button form events 368 | * @param fn 369 | */ 370 | public onCancel(fn: (change: Record) => any): void 371 | } 372 | -------------------------------------------------------------------------------- /src/ui.mock.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable unused-imports/no-unused-vars */ 2 | 3 | import type { 4 | IHomebridgePluginUi, 5 | IHomebridgeUiToastHelper, 6 | PluginConfig, 7 | PluginFormSchema, 8 | PluginMetadata, 9 | PluginSchema, 10 | ServerEnvMetadata, 11 | } from './ui.interface' 12 | 13 | export class MockHomebridgePluginUi extends EventTarget implements IHomebridgePluginUi { 14 | public mockPluginConfig: PluginConfig[] = [] 15 | 16 | public mockPluginSchema: PluginSchema = { 17 | pluginAlias: 'HomebridgeTest', 18 | pluginType: 'platform', 19 | } 20 | 21 | public plugin: PluginMetadata = { 22 | name: 'homebridge-test', 23 | description: 'description sample text', 24 | verifiedPlugin: false, 25 | installPath: '/path/to/plugin', 26 | latestVersion: 'v1.0.0', 27 | installedVersion: 'v1.0.0', 28 | updateAvailable: false, 29 | globalInstall: true, 30 | settingsSchema: true, 31 | publicPackage: true, 32 | links: [], 33 | funding: [], 34 | } 35 | 36 | public serverEnv: ServerEnvMetadata = { 37 | env: { 38 | ableToConfigureSelf: true, 39 | enableAccessories: true, 40 | enableTerminalAccess: true, 41 | homebridgeInstanceName: 'Homebridge 1B77', 42 | nodeVersion: 'v14.15.0', 43 | packageName: 'homebridge-config-ui-x', 44 | packageVersion: '4.32.1', 45 | platform: 'darwin', 46 | runningInDocker: false, 47 | runningInLinux: false, 48 | dockerOfflineUpdate: false, 49 | serviceMode: true, 50 | temperatureUnits: 'c', 51 | lang: null, 52 | instanceId: 'eca2e929f20e8a1292893a2852cba6c10d5efb1a77e20238ce9fe2da8da75b88', 53 | }, 54 | formAuth: true, 55 | theme: 'auto', 56 | serverTimestamp: new Date().toISOString(), 57 | } 58 | 59 | constructor() { 60 | super() 61 | this.dispatchEvent(new Event('ready')) 62 | } 63 | 64 | public toast = new MockHomebridgeUiToastHelper() 65 | 66 | public fixScrollHeight() { } 67 | public closeSettings() { } 68 | public showSpinner() { } 69 | public hideSpinner() { } 70 | public disableSaveButton() {} 71 | public enableSaveButton() {} 72 | public showSchemaForm() { } 73 | public hideSchemaForm() { } 74 | public endForm() { } 75 | 76 | public createForm(schema: PluginFormSchema, data: any) { 77 | return new MockHomebridgeUiFormHelper(this, schema, data) 78 | } 79 | 80 | public async getPluginConfig() { 81 | return this.mockPluginConfig 82 | } 83 | 84 | public async updatePluginConfig(pluginConfig: PluginConfig[]) { 85 | this.mockPluginConfig = pluginConfig 86 | return this.mockPluginConfig 87 | } 88 | 89 | public async savePluginConfig() { } 90 | 91 | public async getPluginConfigSchema() { 92 | return this.mockPluginSchema 93 | } 94 | 95 | public async request(path: string, body: string) { 96 | return {} 97 | } 98 | 99 | public async userCurrentLightingMode(): Promise<'light' | 'dark'> { 100 | return 'light' 101 | } 102 | 103 | public async i18nCurrentLang() { 104 | return 'en' 105 | } 106 | 107 | public async i18nGetTranslation() { 108 | return {} 109 | } 110 | 111 | public async getCachedAccessories() { 112 | return [] 113 | } 114 | } 115 | 116 | export class MockHomebridgeUiToastHelper implements IHomebridgeUiToastHelper { 117 | success(message: string, title: string) { } 118 | error(message: string, title: string) { } 119 | warning(message: string, title: string) { } 120 | info(message: string, title: string) { } 121 | } 122 | 123 | export class MockHomebridgeUiFormHelper { 124 | constructor( 125 | parent: IHomebridgePluginUi, 126 | schema: PluginFormSchema, 127 | data: any, 128 | submitButton?: string, 129 | cancelButton?: string, 130 | ) { } 131 | 132 | public end() { } 133 | public onChange(fn) {} 134 | public onSubmit(fn) {} 135 | public onCancel(fn) {} 136 | } 137 | -------------------------------------------------------------------------------- /src/ui.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This script is injected into a plugins custom settings ui by the Homebridge UI 3 | * You should not include it in your own code, however you can use it for type information if desired. 4 | * It provides the interface to interact with the Homebridge UI service. 5 | */ 6 | 7 | /* eslint-disable no-console */ 8 | 9 | let EventTargetConstructor = window.EventTarget 10 | 11 | /** 12 | * Polyfill for older browsers that do not support EventTarget as a constructor. 13 | * https://developer.mozilla.org/en-US/docs/Web/API/EventTarget 14 | */ 15 | if (!Object.prototype.hasOwnProperty.call(window.EventTarget, 'caller')) { 16 | EventTargetConstructor = function (this: EventTarget) { 17 | // @ts-expect-error - TS2339: Property listeners does not exist on type EventTarget 18 | this.listeners = {} 19 | } as unknown as { new(): EventTarget, prototype: EventTarget } 20 | 21 | // @ts-expect-error - TS2339: Property listeners does not exist on type EventTarget 22 | EventTargetConstructor.prototype.listeners = null 23 | EventTargetConstructor.prototype.addEventListener = function (type, callback) { 24 | // @ts-expect-error - TS2339: Property listeners does not exist on type EventTarget 25 | if (!(type in this.listeners)) { 26 | // @ts-expect-error - TS2339: Property listeners does not exist on type EventTarget 27 | this.listeners[type] = [] 28 | } 29 | 30 | // @ts-expect-error - TS2339: Property listeners does not exist on type EventTarget 31 | this.listeners[type].push(callback) 32 | } 33 | 34 | EventTargetConstructor.prototype.removeEventListener = function (type, callback) { 35 | // @ts-expect-error - TS2339: Property listeners does not exist on type EventTarget 36 | if (!(type in this.listeners)) { 37 | return 38 | } 39 | 40 | // @ts-expect-error - TS2339: Property listeners does not exist on type EventTarget 41 | const stack = this.listeners[type] 42 | for (let i = 0, l = stack.length; i < l; i++) { 43 | if (stack[i] === callback) { 44 | stack.splice(i, 1) 45 | return 46 | } 47 | } 48 | } 49 | 50 | EventTargetConstructor.prototype.dispatchEvent = function (event) { 51 | // @ts-expect-error - TS2339: Property listeners does not exist on type EventTarget 52 | if (!(event.type in this.listeners)) { 53 | return true 54 | } 55 | 56 | // @ts-expect-error - TS2339: Property listeners does not exist on type EventTarget 57 | const stack = this.listeners[event.type].slice() 58 | 59 | for (let i = 0, l = stack.length; i < l; i++) { 60 | stack[i].call(this, event) 61 | } 62 | return !event.defaultPrevented 63 | } 64 | } 65 | 66 | class HomebridgePluginUi extends EventTargetConstructor { 67 | private origin = '' 68 | private lastBodyHeight = 0 69 | private linkRequests: Promise[] = [] 70 | 71 | public toast = new HomebridgeUiToastHelper() 72 | // @ts-expect-error - TS2339: Property _homebridge does not exist on type Window & typeof globalThis 73 | public plugin = window._homebridge.plugin 74 | 75 | // @ts-expect-error - TS2339: Property _homebridge does not exist on type Window & typeof globalThis 76 | public serverEnv = window._homebridge.serverEnv 77 | 78 | constructor() { 79 | super() 80 | window.addEventListener('message', this._handleIncomingMessage.bind(this), false) 81 | } 82 | 83 | private async _handleIncomingMessage(e) { 84 | switch (e.data.action) { 85 | case 'ready': { 86 | await Promise.all(this.linkRequests) 87 | this.origin = e.origin 88 | document.body.style.display = 'block' 89 | this.dispatchEvent(new Event('ready')) 90 | this.fixScrollHeight() 91 | this._monitorFrameHeight() 92 | break 93 | } 94 | case 'response': { 95 | this.dispatchEvent(new MessageEvent(e.data.requestId, { 96 | data: e.data, 97 | })) 98 | break 99 | } 100 | case 'stream': { 101 | this.dispatchEvent(new MessageEvent(e.data.event, { 102 | data: e.data.data, 103 | })) 104 | break 105 | } 106 | case 'body-class': { 107 | this._setBodyClass(e) 108 | break 109 | } 110 | case 'inline-style': { 111 | this._setInlineStyle(e) 112 | break 113 | } 114 | case 'link-element': { 115 | this._setLinkElement(e) 116 | break 117 | } 118 | default: 119 | console.log(e.data) 120 | } 121 | } 122 | 123 | public _postMessage(message) { 124 | window.parent.postMessage(message, this.origin || '*') 125 | } 126 | 127 | private _setBodyClass(e) { 128 | document.body.classList.add(e.data.class) 129 | } 130 | 131 | private _setInlineStyle(e) { 132 | const styleElement = document.createElement('style') 133 | styleElement.innerHTML = e.data.style 134 | document.head.appendChild(styleElement) 135 | } 136 | 137 | private _setLinkElement(e) { 138 | const request = new Promise((resolve) => { 139 | const linkElement = document.createElement('link') 140 | linkElement.setAttribute('href', e.data.href) 141 | linkElement.setAttribute('rel', e.data.rel) 142 | linkElement.onload = resolve 143 | linkElement.onerror = resolve 144 | document.head.appendChild(linkElement) 145 | }) 146 | this.linkRequests.push(request) 147 | } 148 | 149 | private _monitorFrameHeight() { 150 | if (window.ResizeObserver) { 151 | // use ResizeObserver if available 152 | const resizeObserver = new window.ResizeObserver(() => { 153 | this.fixScrollHeight() 154 | }) 155 | resizeObserver.observe(document.body) 156 | } else { 157 | // fall back to polling 158 | setInterval(() => { 159 | if (document.body.scrollHeight !== this.lastBodyHeight) { 160 | this.lastBodyHeight = document.body.scrollHeight 161 | this.fixScrollHeight() 162 | } 163 | }, 250) 164 | } 165 | } 166 | 167 | private async _requestResponse(payload): Promise { 168 | // generate a random request id, so we can link the response 169 | const requestId = Math.random().toString(36).substring(2) 170 | payload.requestId = requestId 171 | 172 | // post message to parent 173 | this._postMessage(payload) 174 | 175 | // wait for response 176 | return new Promise((resolve, reject) => { 177 | const responseHandler = (event) => { 178 | this.removeEventListener(requestId, responseHandler) 179 | if (event.data.success) { 180 | resolve(event.data.data) 181 | } else { 182 | reject(event.data.data) 183 | } 184 | } 185 | 186 | this.addEventListener(requestId, responseHandler) 187 | }) 188 | } 189 | 190 | public fixScrollHeight(): void { 191 | this._postMessage({ action: 'scrollHeight', scrollHeight: document.body.scrollHeight }) 192 | } 193 | 194 | public closeSettings(): void { 195 | this._postMessage({ action: 'close' }) 196 | } 197 | 198 | public showSpinner(): void { 199 | this._postMessage({ action: 'spinner.show' }) 200 | } 201 | 202 | public hideSpinner(): void { 203 | this._postMessage({ action: 'spinner.hide' }) 204 | } 205 | 206 | public disableSaveButton(): void { 207 | this._postMessage({ action: 'button.save.disabled' }) 208 | } 209 | 210 | public enableSaveButton(): void { 211 | this._postMessage({ action: 'button.save.enabled' }) 212 | } 213 | 214 | public showSchemaForm(): void { 215 | this._postMessage({ action: 'schema.show' }) 216 | } 217 | 218 | public hideSchemaForm(): void { 219 | this._postMessage({ action: 'schema.hide' }) 220 | } 221 | 222 | public createForm(schema, data, submitButton?: string, cancelButton?: string) { 223 | return new HomebridgeUiFormHelper(this, schema, data, submitButton, cancelButton) 224 | } 225 | 226 | public endForm() { 227 | this._postMessage({ action: 'form.end' }) 228 | } 229 | 230 | public async getPluginConfig() { 231 | return await this._requestResponse({ action: 'config.get' }) 232 | } 233 | 234 | public async updatePluginConfig(pluginConfig) { 235 | return await this._requestResponse({ action: 'config.update', pluginConfig }) 236 | } 237 | 238 | public async savePluginConfig() { 239 | return await this._requestResponse({ action: 'config.save' }) 240 | } 241 | 242 | public async getPluginConfigSchema() { 243 | return await this._requestResponse({ action: 'config.schema' }) 244 | } 245 | 246 | public async getCachedAccessories(): Promise> { 247 | return await this._requestResponse({ action: 'cachedAccessories.get' }) 248 | } 249 | 250 | public async request(path: string, body?: any) { 251 | return await this._requestResponse({ action: 'request', path, body }) 252 | } 253 | 254 | public async userCurrentLightingMode(): Promise<'dark' | 'light'> { 255 | return await this._requestResponse({ action: 'user.lightingMode' }) 256 | } 257 | 258 | public async i18nCurrentLang(): Promise { 259 | return await this._requestResponse({ action: 'i18n.lang' }) 260 | } 261 | 262 | public async i18nGetTranslation(): Promise> { 263 | return await this._requestResponse({ action: 'i18n.translations' }) 264 | } 265 | } 266 | 267 | class HomebridgeUiToastHelper { 268 | private _postMessage(type: string, message: string, title?: string) { 269 | window.parent.postMessage({ action: `toast.${type}`, message, title }, '*') 270 | } 271 | 272 | public success(message: string, title?: string): void { 273 | this._postMessage('success', message, title) 274 | } 275 | 276 | public error(message: string, title?: string): void { 277 | this._postMessage('error', message, title) 278 | } 279 | 280 | public warning(message: string, title?: string): void { 281 | this._postMessage('warning', message, title) 282 | } 283 | 284 | public info(message: string, title?: string): void { 285 | this._postMessage('info', message, title) 286 | } 287 | } 288 | 289 | class HomebridgeUiFormHelper { 290 | private formId = Math.random().toString(36).substring(2) 291 | private _changeHandle?: (change) => any 292 | private _submitHandle?: (change) => any 293 | private _cancelHandle?: (change) => any 294 | public end: () => void 295 | 296 | constructor( 297 | private parent: HomebridgePluginUi, 298 | schema: Record, 299 | data: Record, 300 | submitButton?: string, 301 | cancelButton?: string, 302 | ) { 303 | this.parent._postMessage({ action: 'form.create', formId: this.formId, schema, data, submitButton, cancelButton }) 304 | 305 | const handle = this._eventHandle.bind(this) 306 | 307 | this.parent.addEventListener(this.formId, handle) 308 | 309 | this.end = () => { 310 | this.parent.removeEventListener(this.formId, handle) 311 | this.parent._postMessage({ action: 'form.end', formId: this.formId, schema, data }) 312 | } 313 | } 314 | 315 | private _eventHandle(event) { 316 | switch (event.data.formEvent) { 317 | case 'change': { 318 | // general change events 319 | if (this._changeHandle && typeof this._changeHandle === 'function') { 320 | this._changeHandle(event.data.formData) 321 | } else { 322 | console.info('Homebridge Custom Plugin UI: Missing form onChange handler.') 323 | } 324 | break 325 | } 326 | case 'submit': { 327 | // submit form events 328 | if (this._submitHandle && typeof this._submitHandle === 'function') { 329 | this._submitHandle(event.data.formData) 330 | } else { 331 | console.info('Homebridge Custom Plugin UI: Missing form onSubmit handler.') 332 | } 333 | break 334 | } 335 | case 'cancel': { 336 | // cancel form events 337 | if (this._cancelHandle && typeof this._cancelHandle === 'function') { 338 | this._cancelHandle(event.data.formData) 339 | } else { 340 | console.info('Homebridge Custom Plugin UI: Missing form onCancel handler.') 341 | } 342 | break 343 | } 344 | default: { 345 | console.info('Unknown form event type:', event.data) 346 | } 347 | } 348 | } 349 | 350 | public onChange(fn) { 351 | if (typeof fn !== 'function') { 352 | console.error('Homebridge Custom Plugin UI: Form onChange handler must be a function.') 353 | return 354 | } 355 | this._changeHandle = fn 356 | } 357 | 358 | public onSubmit(fn) { 359 | if (typeof fn !== 'function') { 360 | console.error('Homebridge Custom Plugin UI: Form onSubmit handler must be a function.') 361 | return 362 | } 363 | this._submitHandle = fn 364 | } 365 | 366 | public onCancel(fn) { 367 | if (typeof fn !== 'function') { 368 | console.error('Homebridge Custom Plugin UI: Form onCancel handler must be a function.') 369 | return 370 | } 371 | this._cancelHandle = fn 372 | } 373 | } 374 | 375 | // @ts-expect-error - TS2339: Property _homebridge does not exist on type Window & typeof globalThis 376 | window.homebridge = new HomebridgePluginUi() 377 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": [ 5 | "ESNext", 6 | "DOM" 7 | ], 8 | "rootDir": "./src", 9 | "module": "ESNext", 10 | "strict": true, 11 | "noImplicitAny": false, 12 | "declaration": true, 13 | "declarationMap": true, 14 | "outDir": "./dist", 15 | "sourceMap": true, 16 | "esModuleInterop": true, 17 | "skipLibCheck": true 18 | }, 19 | "include": [ 20 | "src/" 21 | ], 22 | "exclude": [ 23 | "src/ui.ts" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.ui.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ESNext", 5 | "module": "ESNext" 6 | }, 7 | "include": [ 8 | "src/ui.ts" 9 | ], 10 | "exclude": [ 11 | "**/*.spec.ts" 12 | ] 13 | } 14 | --------------------------------------------------------------------------------