├── .docker └── scripts │ └── 100-envsubst-on-app-envs.sh ├── .dockerignore ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yaml │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── build_container.yml │ ├── ci.yml │ ├── i18n.yml │ ├── publish.yml │ └── stale.yml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── biome.json ├── docker-compose.yml ├── index.d.ts ├── index.js ├── mocks ├── bridgeDefinitions.ts ├── bridgeDevices.ts ├── bridgeExtensions.ts ├── bridgeGroups.ts ├── bridgeInfo.ts ├── bridgeLogging.ts ├── bridgeState.ts ├── deviceAvailability.ts ├── deviceState.ts ├── generateExternalDefinitionResponse.ts ├── networkMapResponse.ts ├── permitJoinResponse.ts ├── touchlinkResponse.ts └── ws.ts ├── package-lock.json ├── package.json ├── screenshots ├── device-exposes.png ├── device-info.png ├── devices-t1.png ├── devices-t2.png ├── devices-t3.png ├── devices-t4.png └── network-data.png ├── scripts ├── override-z2m-en.mjs └── override-zhc-en.mjs ├── src ├── ErrorBoundary.tsx ├── Main.tsx ├── WebSocketApiRouter.tsx ├── WebSocketApiRouterContext.ts ├── components │ ├── Button.tsx │ ├── ConfirmButton.tsx │ ├── PopoverDropdown.tsx │ ├── ScrollToTop.tsx │ ├── ThemeSwitcher.tsx │ ├── dashboard-page │ │ ├── DashboardFeatureWrapper.tsx │ │ └── index.tsx │ ├── device-page │ │ ├── AddScene.tsx │ │ ├── AttributeEditor.tsx │ │ ├── BindRow.tsx │ │ ├── CommandExecutor.tsx │ │ ├── ExternalDefinition.tsx │ │ ├── HeaderDeviceSelector.tsx │ │ ├── LastLogResult.tsx │ │ ├── RecallRemove.tsx │ │ ├── ReportingRow.tsx │ │ ├── ScenePicker.tsx │ │ ├── index.tsx │ │ └── tabs │ │ │ ├── Bind.tsx │ │ │ ├── Clusters.tsx │ │ │ ├── DevConsole.tsx │ │ │ ├── DeviceInfo.tsx │ │ │ ├── DeviceSettings.tsx │ │ │ ├── DeviceSpecificSettings.tsx │ │ │ ├── Exposes.tsx │ │ │ ├── Groups.tsx │ │ │ ├── Reporting.tsx │ │ │ ├── Scene.tsx │ │ │ └── State.tsx │ ├── device │ │ ├── DeviceCard.tsx │ │ ├── DeviceControlEditName.tsx │ │ ├── DeviceControlGroup.tsx │ │ ├── DeviceControlUpdateDesc.tsx │ │ ├── DeviceImage.tsx │ │ ├── ErrorBoundary.tsx │ │ ├── LazyImage.tsx │ │ └── index.tsx │ ├── editors │ │ ├── ColorEditor.tsx │ │ ├── EnumEditor.tsx │ │ ├── ListEditor.tsx │ │ ├── RangeEditor.tsx │ │ ├── TextEditor.tsx │ │ └── index.ts │ ├── features │ │ ├── BaseViewer.tsx │ │ ├── Binary.tsx │ │ ├── Climate.tsx │ │ ├── Color.tsx │ │ ├── Cover.tsx │ │ ├── Enum.tsx │ │ ├── Fan.tsx │ │ ├── Feature.tsx │ │ ├── FeatureSubFeatures.tsx │ │ ├── FeatureWrapper.tsx │ │ ├── Gradient.tsx │ │ ├── Light.tsx │ │ ├── List.tsx │ │ ├── Lock.tsx │ │ ├── NoAccessError.tsx │ │ ├── Numeric.tsx │ │ ├── Switch.tsx │ │ ├── Text.tsx │ │ └── index.tsx │ ├── form-fields │ │ ├── ArrayField.tsx │ │ ├── CheckboxField.tsx │ │ ├── CheckboxesField.tsx │ │ ├── DebouncedInput.tsx │ │ ├── InputField.tsx │ │ ├── NumberField.tsx │ │ ├── SelectField.tsx │ │ └── TextareaField.tsx │ ├── group-page │ │ ├── AddDeviceToGroup.tsx │ │ ├── GroupMember.tsx │ │ ├── GroupMembers.tsx │ │ ├── HeaderGroupSelector.tsx │ │ └── tabs │ │ │ ├── Devices.tsx │ │ │ └── GroupSettings.tsx │ ├── json-schema │ │ └── SettingsList.tsx │ ├── modal │ │ ├── Modal.tsx │ │ └── components │ │ │ ├── AuthModal.tsx │ │ │ ├── DialogConfirmationModal.tsx │ │ │ ├── EditDeviceDescModal.tsx │ │ │ ├── RemoveDeviceModal.tsx │ │ │ ├── RenameDeviceModal.tsx │ │ │ └── RenameGroupModal.tsx │ ├── navbar │ │ ├── ApiUrlSwitcher.tsx │ │ ├── NavBar.tsx │ │ └── PermitJoinButton.tsx │ ├── network-page │ │ ├── RawNetworkData.tsx │ │ ├── RawNetworkMap.tsx │ │ ├── index.tsx │ │ ├── raw-data │ │ │ ├── RawRelation.tsx │ │ │ └── RawRelationGroup.tsx │ │ └── raw-map │ │ │ ├── ContextMenu.tsx │ │ │ ├── Controls.tsx │ │ │ ├── Legend.tsx │ │ │ └── SliderField.tsx │ ├── ota-page │ │ ├── OtaControlGroup.tsx │ │ └── OtaFileVersion.tsx │ ├── pickers │ │ ├── AttributePicker.tsx │ │ ├── ClusterMultiPicker.tsx │ │ ├── ClusterSinglePicker.tsx │ │ ├── DevicePicker.tsx │ │ ├── EndpointPicker.tsx │ │ ├── GroupPicker.tsx │ │ └── index.tsx │ ├── settings-page │ │ ├── ImageLocaliser.tsx │ │ ├── Stats.tsx │ │ └── tabs │ │ │ ├── About.tsx │ │ │ ├── Bridge.tsx │ │ │ ├── DevConsole.tsx │ │ │ ├── Donate.tsx │ │ │ ├── Frontend.tsx │ │ │ ├── Settings.tsx │ │ │ └── Tools.tsx │ ├── table │ │ ├── Table.tsx │ │ └── TextFilter.tsx │ ├── toasts │ │ ├── Toast.tsx │ │ └── Toasts.tsx │ └── value-decorators │ │ ├── Availability.tsx │ │ ├── Countdown.tsx │ │ ├── DisplayValue.tsx │ │ ├── LastSeen.tsx │ │ ├── Lqi.tsx │ │ ├── ModelLink.tsx │ │ ├── OtaLink.tsx │ │ ├── PowerSource.tsx │ │ └── VendorLink.tsx ├── consts.ts ├── declarations.d.ts ├── envs.ts ├── hooks │ ├── useApiWebSocket.ts │ ├── useApp.ts │ ├── useReRenderTracer.ts │ └── useWindowDimensions.ts ├── i18n │ ├── LanguageSwitcher.tsx │ ├── index.ts │ └── locales │ │ ├── bg.json │ │ ├── ca.json │ │ ├── chs.json │ │ ├── cs.json │ │ ├── da.json │ │ ├── de.json │ │ ├── en.json │ │ ├── es.json │ │ ├── eu.json │ │ ├── fi.json │ │ ├── fr.json │ │ ├── hu.json │ │ ├── it.json │ │ ├── ko.json │ │ ├── nl.json │ │ ├── no.json │ │ ├── pl.json │ │ ├── ptbr.json │ │ ├── ru.json │ │ ├── sv.json │ │ ├── tr.json │ │ ├── ua.json │ │ └── zh.json ├── images │ ├── apple-touch-icon.png │ ├── favicon.ico │ └── generic-zigbee-device.png ├── index.html ├── index.tsx ├── localStoreConsts.ts ├── pages │ ├── Dashboard.tsx │ ├── DevicePage.tsx │ ├── DevicesPage.tsx │ ├── GroupPage.tsx │ ├── GroupsPage.tsx │ ├── HomePage.tsx │ ├── LogsPage.tsx │ ├── NetworkPage.tsx │ ├── OtaPage.tsx │ ├── SettingsPage.tsx │ └── TouchlinkPage.tsx ├── store.ts ├── styles │ ├── NotoSans-Regular.ttf │ └── styles.global.css ├── types.ts ├── utils.ts └── vite.d.ts ├── test └── index.test.tsx ├── tsconfig.json └── vite.config.mts /.docker/scripts/100-envsubst-on-app-envs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -ex 4 | 5 | # find the file with the template envs 6 | envs=$(ls -t /usr/share/nginx/html/assets/envs*.js | head -n1) 7 | 8 | envsubst < "$envs" > ./envs_temp 9 | cp ./envs_temp "$envs" 10 | rm ./envs_temp 11 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .github 3 | coverage 4 | node_modules 5 | screenshots 6 | scripts 7 | test 8 | .dockerignore 9 | Dockerfile -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: Nerivec 4 | buy_me_a_coffee: Nerivec 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yaml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Create a report to help us improve 3 | labels: [bug] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | ### Before submitting any bug report, make sure that you can reproduce the issue with latest browsers versions and on latest Zigbee2MQTT dev branch 9 | https://www.zigbee2mqtt.io/advanced/more/switch-to-dev-branch.html 10 | Thanks for taking the time to fill out this bug report! 11 | - type: textarea 12 | id: describe 13 | attributes: 14 | label: Describe the bug 15 | description: | 16 | A clear and concise description of what the bug is
17 | If relevant, attach the state file downloaded from the error page or from `Zigbee2MQTT frontend > Settings > Tools > Download state` using the button below the text box (or drag & drop) 18 | validations: 19 | required: true 20 | - type: textarea 21 | id: reproduce 22 | attributes: 23 | label: To Reproduce 24 | description: Steps to reproduce the behavior 25 | validations: 26 | required: true 27 | - type: textarea 28 | id: expected 29 | attributes: 30 | label: Expected behavior 31 | description: A clear and concise description of what you expected to happen 32 | - type: dropdown 33 | id: browsers 34 | validations: 35 | required: true 36 | attributes: 37 | label: Affected browsers 38 | multiple: true 39 | options: 40 | - Firefox 41 | - Chrome 42 | - Safari 43 | - Microsoft Edge 44 | - type: textarea 45 | id: stacktrace 46 | attributes: 47 | label: Stacktrace 48 | render: shell 49 | -------------------------------------------------------------------------------- /.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 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | commit-message: 5 | prefix: fix 6 | directory: "/" 7 | schedule: 8 | interval: "weekly" 9 | groups: 10 | production-dependencies: 11 | applies-to: version-updates 12 | dependency-type: "production" 13 | update-types: 14 | - "minor" 15 | - "patch" 16 | development-dependencies: 17 | applies-to: version-updates 18 | dependency-type: "development" 19 | update-types: 20 | - "minor" 21 | - "patch" 22 | 23 | - package-ecosystem: github-actions 24 | commit-message: 25 | prefix: fix 26 | directory: "/" 27 | schedule: 28 | interval: weekly 29 | -------------------------------------------------------------------------------- /.github/workflows/build_container.yml: -------------------------------------------------------------------------------- 1 | name: Build container 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | tag: 7 | description: 'Tag to mimic Git tag.' 8 | required: true 9 | default: 'v1.0.0' 10 | type: string 11 | 12 | permissions: 13 | contents: read 14 | 15 | env: 16 | REGISTRY: ghcr.io 17 | 18 | jobs: 19 | build: 20 | runs-on: ubuntu-latest 21 | permissions: 22 | packages: write 23 | steps: 24 | - uses: actions/checkout@v4 25 | 26 | - uses: actions/setup-node@v4 27 | with: 28 | node-version-file: 'package.json' 29 | 30 | - run: npm ci 31 | 32 | - name: Build for production 33 | run: NODE_ENV=production npm run build 34 | 35 | - name: Log in to the GitHub container registry 36 | uses: docker/login-action@v3 37 | with: 38 | registry: ${{ env.REGISTRY }} 39 | username: ${{ github.repository_owner }} 40 | password: ${{ secrets.GITHUB_TOKEN }} 41 | 42 | - name: Docker setup - QEMU 43 | uses: docker/setup-qemu-action@v3 44 | 45 | - name: Docker setup - Buildx 46 | uses: docker/setup-buildx-action@v3 47 | 48 | - name: Docker meta 49 | uses: docker/metadata-action@v5 50 | id: meta 51 | with: 52 | images: | 53 | ghcr.io/nerivec/zigbee2mqtt-windfront 54 | tags: | 55 | type=semver,pattern={{version}},value=${{ inputs.tag }} 56 | type=semver,pattern={{major}}.{{minor}},value=${{ inputs.tag }} 57 | type=semver,pattern={{major}},value=${{ inputs.tag }} 58 | 59 | - name: Docker build and push 60 | uses: docker/build-push-action@v6 61 | with: 62 | context: . 63 | file: Dockerfile 64 | platforms: linux/arm64/v8,linux/amd64,linux/arm/v6,linux/arm/v7,linux/riscv64,linux/386 65 | tags: ${{ steps.meta.outputs.tags }} 66 | push: true 67 | build-args: | 68 | VERSION=${{ inputs.tag }} 69 | DATE=${{ github.event.repository.updated_at }} 70 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | permissions: 8 | contents: read 9 | 10 | env: 11 | REGISTRY: ghcr.io 12 | 13 | jobs: 14 | checks: 15 | runs-on: ubuntu-latest 16 | permissions: 17 | packages: write 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version-file: 'package.json' 24 | 25 | - run: npm ci 26 | 27 | - run: npm run typecheck 28 | 29 | - run: npm run build 30 | 31 | - run: npm run check:ci 32 | 33 | - run: npm run test:cov 34 | 35 | # build container when triggered by release (push on tag) 36 | - name: Log in to the GitHub container registry 37 | if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push' 38 | uses: docker/login-action@v3 39 | with: 40 | registry: ${{ env.REGISTRY }} 41 | username: ${{ github.repository_owner }} 42 | password: ${{ secrets.GITHUB_TOKEN }} 43 | 44 | - name: Docker setup - QEMU 45 | if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push' 46 | uses: docker/setup-qemu-action@v3 47 | 48 | - name: Docker setup - Buildx 49 | if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push' 50 | uses: docker/setup-buildx-action@v3 51 | 52 | - name: Docker meta 53 | if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push' 54 | uses: docker/metadata-action@v5 55 | id: meta 56 | with: 57 | images: | 58 | ghcr.io/nerivec/zigbee2mqtt-windfront 59 | tags: | 60 | type=semver,pattern={{version}} 61 | type=semver,pattern={{major}}.{{minor}} 62 | type=semver,pattern={{major}} 63 | 64 | - name: Docker build and push 65 | if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push' 66 | uses: docker/build-push-action@v6 67 | with: 68 | context: . 69 | file: Dockerfile 70 | platforms: linux/arm64/v8,linux/amd64,linux/arm/v6,linux/arm/v7,linux/riscv64,linux/386 71 | tags: ${{ steps.meta.outputs.tags }} 72 | push: true 73 | build-args: | 74 | VERSION=${{ github.ref_name }} 75 | DATE=${{ github.event.repository.updated_at }} 76 | -------------------------------------------------------------------------------- /.github/workflows/i18n.yml: -------------------------------------------------------------------------------- 1 | name: Update i18n EN translations 2 | 3 | on: 4 | schedule: 5 | - cron: '0 12 1 * *' 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | update: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | # Setup .npmrc file to publish to npm 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version-file: 'package.json' 23 | registry-url: 'https://registry.npmjs.org' 24 | 25 | - run: npm ci 26 | 27 | - run: npm i -D zigbee-herdsman-converters 28 | 29 | - run: node ./scripts/override-z2m-en.mjs 30 | 31 | - run: node ./scripts/override-zhc-en.mjs 32 | 33 | - run: npm run check 34 | 35 | - run: npm un zigbee-herdsman-converters 36 | 37 | - name: Commit changes 38 | run: | 39 | git config --global user.name 'github-actions[bot]' 40 | git config --global user.email 'github-actions[bot]@users.noreply.github.com' 41 | git add ./src/i18n/locales/en.json 42 | git commit -m "Update i18n EN translations" || echo 'Nothing to commit' 43 | git push 44 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish package to npmjs 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | publish: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: read 15 | id-token: write 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | # Setup .npmrc file to publish to npm 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version-file: 'package.json' 23 | registry-url: 'https://registry.npmjs.org' 24 | 25 | - run: npm ci 26 | 27 | - run: npm publish --provenance --access public 28 | env: 29 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 30 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Close stale issues and PRs' 2 | on: 3 | schedule: 4 | - cron: '30 4 * * *' 5 | 6 | permissions: 7 | contents: read 8 | 9 | jobs: 10 | stale: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | # contents: write # only for delete-branch option 14 | issues: write 15 | pull-requests: write 16 | steps: 17 | - uses: actions/stale@v9 18 | with: 19 | stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.' 20 | stale-pr-message: 'This pull request is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.' 21 | days-before-stale: 30 22 | days-before-close: 5 23 | exempt-issue-labels: dont-stale 24 | exempt-pr-labels: dont-stale 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 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 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["biomejs.biome", "vitest.explorer"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "biomejs.biome", 3 | "notebook.defaultFormatter": "biomejs.biome", 4 | "editor.tabSize": 4, 5 | "editor.insertSpaces": true, 6 | "editor.formatOnSave": true, 7 | "editor.codeActionsOnSave": { 8 | "source.organizeImports.biome": "explicit" 9 | }, 10 | "files.defaultLanguage": "typescript", 11 | "files.eol": "\n" 12 | } 13 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Setup 4 | 5 | Installing npm dependencies 6 | 7 | ```bash 8 | npm i 9 | ``` 10 | 11 | ### Using mock data 12 | 13 | Data from the `mocks` folder is used to create a fake Zigbee2MQTT server connection. 14 | 15 | ```bash 16 | npm start 17 | open http://localhost:5173/ 18 | ``` 19 | 20 | ### Using your Zigbee2MQTT instance 21 | 22 | Use the `Z2M_API_URI` environment variable with the appropriate IP & port. Example: 23 | 24 | ```bash 25 | Z2M_API_URI="ws://192.168.1.200:8080" npm start 26 | open http://localhost:5173/ 27 | ``` 28 | 29 | ## Format & lint 30 | 31 | All contributions are expected to match the repository's formatting and linting rules (using [biomejs](https://biomejs.dev/)). 32 | 33 | ```bash 34 | npm run check 35 | ``` 36 | 37 | ## Test 38 | 39 | All contributions are expected to pass the current tests, and implement coverage for new features as appropriate. 40 | 41 | ```bash 42 | npm run test:cov 43 | ``` 44 | 45 | ## Build 46 | 47 | All contributions are expected to build successfully. 48 | 49 | ```bash 50 | npm run build 51 | ``` 52 | 53 | ## Translating 54 | 55 | [Locales directory](./src/i18n/locales) 56 | 57 | You can edit the JSON files directly from Github, using the [EN](./src/i18n/locales/en.json) file as reference. _The EN file contains all the keys to support for full coverage in a given language._ If starting from a blank JSON, copy the content of the EN file in it and then translate the values. 58 | 59 | For example: 60 | 61 | en.json 62 | ```json 63 | { 64 | "common": { 65 | "action": "Action", 66 | [...] 67 | ``` 68 | es.json 69 | ```json 70 | { 71 | "common": { 72 | "action": "Acción", 73 | [...] 74 | ``` 75 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:alpine-slim AS prod 2 | 3 | ARG DATE 4 | ARG VERSION 5 | 6 | LABEL org.opencontainers.image.authors="Nerivec" 7 | LABEL org.opencontainers.image.title="Zigbee2MQTT WindFront" 8 | LABEL org.opencontainers.image.description="Open Source frontend for Zigbee2MQTT" 9 | LABEL org.opencontainers.image.url="https://github.com/Nerivec/zigbee2mqtt-windfront" 10 | LABEL org.opencontainers.image.documentation="https://github.com/Nerivec/zigbee2mqtt-windfront/wiki" 11 | LABEL org.opencontainers.image.source="https://github.com/Nerivec/zigbee2mqtt-windfront" 12 | LABEL org.opencontainers.image.licenses="GPL-3.0-or-later" 13 | LABEL org.opencontainers.image.created=${DATE} 14 | LABEL org.opencontainers.image.version=${VERSION} 15 | 16 | EXPOSE 80 17 | 18 | COPY .docker/scripts/ /docker-entrypoint.d/ 19 | 20 | RUN chmod +x /docker-entrypoint.d/100-envsubst-on-app-envs.sh 21 | 22 | COPY dist/ /usr/share/nginx/html/ 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Zigbee2MQTT WindFront 2 | 3 | [![Version](https://img.shields.io/npm/v/zigbee2mqtt-windfront.svg)](https://npmjs.org/package/zigbee2mqtt-windfront) 4 | [![CI](https://github.com/Nerivec/zigbee2mqtt-windfront/actions/workflows/ci.yml/badge.svg)](https://github.com/Nerivec/zigbee2mqtt-windfront/actions/workflows/ci.yml) 5 | [![CodeQL](https://github.com/Nerivec/zigbee2mqtt-windfront/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/Nerivec/zigbee2mqtt-windfront/actions/workflows/github-code-scanning/codeql) 6 | 7 | A frontend UI for [Zigbee2MQTT](https://github.com/Koenkk/zigbee2mqtt) using [tailwindcss](https://tailwindcss.com/) & [daisyui](https://daisyui.com). 8 | 9 | https://github.com/Nerivec/zigbee2mqtt-windfront/wiki 10 | 11 | > Based on https://github.com/nurikk/zigbee2mqtt-frontend 12 | 13 | > [!IMPORTANT] 14 | > Currently in beta test! 15 | 16 | ![device-info](./screenshots/device-info.png) 17 | ![device-exposes](./screenshots/device-exposes.png) 18 | ![network-data](./screenshots/network-data.png) 19 | 20 | ### 35 themes offered by the [design library](https://daisyui.com/docs/themes/#list-of-themes)! 21 | 22 | ![devices-t1](./screenshots/devices-t1.png) 23 | ![devices-t2](./screenshots/devices-t2.png) 24 | ![devices-t3](./screenshots/devices-t3.png) 25 | ![devices-t4](./screenshots/devices-t4.png) 26 | 27 | # Contributing 28 | 29 | [CONTRIBUTING](./CONTRIBUTING.md) 30 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | zigbee2mqtt-windfront: 3 | build: . 4 | container_name: zigbee2mqtt-windfront 5 | image: ghcr.io/nerivec/zigbee2mqtt-windfront 6 | restart: unless-stopped 7 | ports: 8 | - 80:80 9 | networks: 10 | - network 11 | environment: 12 | - Z2M_API_URLS=localhost:5173/api,localhost:8080/api 13 | - Z2M_API_NAMES=Instance one,Instance two 14 | networks: 15 | network: 16 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare const windfront: { 2 | getPath: () => string; 3 | }; 4 | 5 | export default windfront; 6 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import { join } from "node:path"; 2 | 3 | export default { 4 | getPath: () => join(import.meta.dirname, "dist"), 5 | }; 6 | -------------------------------------------------------------------------------- /mocks/bridgeGroups.ts: -------------------------------------------------------------------------------- 1 | import type { Group, Message } from "../src/types.js"; 2 | 3 | export const BRIDGE_GROUPS: Message = { 4 | payload: [ 5 | { 6 | description: "Test group description", 7 | friendly_name: "hue lights", 8 | id: 1, 9 | members: [ 10 | { 11 | endpoint: 11, 12 | ieee_address: "0x0017880104292f0a", 13 | }, 14 | { 15 | endpoint: 11, 16 | ieee_address: "0x0017880104dfc05e", 17 | }, 18 | { 19 | endpoint: 11, 20 | ieee_address: "0x0017880103d55d65", 21 | }, 22 | ], 23 | scenes: [ 24 | { 25 | id: 2, 26 | name: "Scene 2", 27 | }, 28 | { 29 | id: 5, 30 | name: "Scene 5", 31 | }, 32 | { 33 | id: 7, 34 | name: "Scene 7", 35 | }, 36 | { 37 | id: 55, 38 | name: "Scene 55", 39 | }, 40 | ], 41 | }, 42 | { 43 | friendly_name: "default_bind_group", 44 | id: 901, 45 | members: [], 46 | scenes: [], 47 | description: null, 48 | }, 49 | ], 50 | topic: "bridge/groups", 51 | }; 52 | -------------------------------------------------------------------------------- /mocks/bridgeState.ts: -------------------------------------------------------------------------------- 1 | import type { Zigbee2MQTTAPI } from "zigbee2mqtt"; 2 | import type { Message } from "../src/types.js"; 3 | 4 | export const BRIDGE_STATE: Message = { 5 | payload: { 6 | state: "online", 7 | }, 8 | topic: "bridge/state", 9 | }; 10 | -------------------------------------------------------------------------------- /mocks/deviceAvailability.ts: -------------------------------------------------------------------------------- 1 | import type { AvailabilityState, Message } from "../src/types.js"; 2 | 3 | export const DEVICE_AVAILABILITY: Message[] = [ 4 | { 5 | payload: { 6 | state: "online", 7 | }, 8 | topic: "0xbc33acfffe17628b/availability", 9 | }, 10 | { 11 | payload: { 12 | state: "online", 13 | }, 14 | topic: "0xbc33acfffe17628a/availability", 15 | }, 16 | { 17 | payload: { 18 | state: "offline", 19 | }, 20 | topic: "hue1/availability", 21 | }, 22 | { 23 | payload: { 24 | state: "offline", 25 | }, 26 | topic: "hue_back_tv/availability", 27 | }, 28 | { 29 | payload: { 30 | state: "online", 31 | }, 32 | topic: "0x00158d000224154d/availability", 33 | }, 34 | { 35 | payload: { 36 | state: "online", 37 | }, 38 | topic: "0x00124b001e73227f1/availability", 39 | }, 40 | { 41 | payload: { 42 | state: "online", 43 | }, 44 | topic: "livingroom/temp_humidity/availability", 45 | }, 46 | { 47 | payload: { 48 | state: "online", 49 | }, 50 | topic: "livingroom/window/availability", 51 | }, 52 | { 53 | payload: { 54 | state: "online", 55 | }, 56 | topic: "livingroom/ac power/availability", 57 | }, 58 | { 59 | payload: { 60 | state: "online", 61 | }, 62 | topic: "0x00158d0004866f11/availability", 63 | }, 64 | { 65 | payload: { 66 | state: "online", 67 | }, 68 | topic: "dining room/ac power/availability", 69 | }, 70 | { 71 | payload: { 72 | state: "offline", 73 | }, 74 | topic: "0x0017880103d55d65/availability", 75 | }, 76 | { 77 | payload: { 78 | state: "offline", 79 | }, 80 | topic: "hue lights/availability", 81 | }, 82 | { 83 | payload: { 84 | state: "online", 85 | }, 86 | topic: "901/availability", 87 | }, 88 | ]; 89 | -------------------------------------------------------------------------------- /mocks/generateExternalDefinitionResponse.ts: -------------------------------------------------------------------------------- 1 | import type { ResponseMessage } from "../src/types.js"; 2 | 3 | export const GENERATE_EXTERNAL_DEFINITION_RESPONSE: ResponseMessage<"bridge/response/device/generate_external_definition"> = { 4 | payload: { 5 | status: "ok", 6 | data: { 7 | id: "$ID", 8 | source: `import * as m from 'zigbee-herdsman-converters/lib/modernExtend'; 9 | 10 | export default { 11 | zigbeeModel: ['random model'], 12 | model: 'random model', 13 | vendor: 'random vendor', 14 | description: 'Automatically generated definition', 15 | extend: [m.temperature(), m.onOff({"powerOnBehavior":false})], 16 | meta: {}, 17 | };`, 18 | }, 19 | }, 20 | topic: "bridge/response/device/generate_external_definition", 21 | }; 22 | -------------------------------------------------------------------------------- /mocks/permitJoinResponse.ts: -------------------------------------------------------------------------------- 1 | import type { ResponseMessage } from "../src/types.js"; 2 | 3 | export const PERMIT_JOIN_RESPONSE: ResponseMessage<"bridge/response/permit_join"> = { 4 | payload: { 5 | status: "ok", 6 | data: { 7 | time: 254, 8 | }, 9 | }, 10 | topic: "bridge/response/permit_join", 11 | }; 12 | -------------------------------------------------------------------------------- /mocks/touchlinkResponse.ts: -------------------------------------------------------------------------------- 1 | import type { ResponseMessage } from "../src/types.js"; 2 | 3 | export const TOUCHLINK_RESPONSE: ResponseMessage<"bridge/response/touchlink/scan"> = { 4 | payload: { 5 | status: "ok", 6 | data: { 7 | found: [ 8 | { 9 | ieee_address: "xxxxx", 10 | channel: 1, 11 | }, 12 | { 13 | ieee_address: "0x00124b001e73227f", 14 | channel: 10, 15 | }, 16 | ], 17 | }, 18 | }, 19 | topic: "bridge/response/touchlink/scan", 20 | }; 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zigbee2mqtt-windfront", 3 | "version": "1.0.6", 4 | "license": "GPL-3.0-or-later", 5 | "type": "module", 6 | "main": "index.js", 7 | "types": "index.d.ts", 8 | "author": "Nerivec", 9 | "engines": { 10 | "node": ">=20.17.0" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/Nerivec/zigbee2mqtt-windfront.git" 15 | }, 16 | "bugs": { 17 | "url": "https://github.com/Nerivec/zigbee2mqtt-windfront/issues" 18 | }, 19 | "homepage": "https://github.com/Nerivec/zigbee2mqtt-windfront/#readme", 20 | "scripts": { 21 | "clean": "rm -rf ./dist", 22 | "build": "vite build", 23 | "preview": "vite preview", 24 | "start": "vite dev", 25 | "test": "vitest run", 26 | "test:cov": "vitest run --coverage", 27 | "typecheck": "tsc --noEmit", 28 | "check": "biome check --write .", 29 | "check:ci": "biome check .", 30 | "prepack": "npm run clean && npm run build" 31 | }, 32 | "devDependencies": { 33 | "@biomejs/biome": "^1.9.4", 34 | "@ebay/nice-modal-react": "^1.2.13", 35 | "@fortawesome/fontawesome-svg-core": "^6.7.2", 36 | "@fortawesome/free-solid-svg-icons": "^6.7.2", 37 | "@fortawesome/react-fontawesome": "^0.2.2", 38 | "@reduxjs/toolkit": "^2.8.2", 39 | "@tailwindcss/vite": "^4.1.8", 40 | "@tanstack/react-table": "^8.21.3", 41 | "@types/file-saver": "^2.0.7", 42 | "@types/json-schema": "^7.0.15", 43 | "@types/lodash": "^4.17.17", 44 | "@types/react": "^19.1.7", 45 | "@types/react-dom": "^19.1.6", 46 | "@types/ws": "^8.18.1", 47 | "@vitejs/plugin-react": "^4.5.2", 48 | "@vitest/coverage-v8": "^3.2.3", 49 | "daisyui": "^5.0.43", 50 | "file-saver": "^2.0.5", 51 | "i18next": "^25.2.1", 52 | "i18next-browser-languagedetector": "^8.1.0", 53 | "jsdom": "^26.1.0", 54 | "jszip": "^3.10.1", 55 | "lodash": "^4.17.21", 56 | "react": "^19.1.0", 57 | "react-app-polyfill": "^3.0.0", 58 | "react-dom": "^19.1.0", 59 | "react-i18next": "^15.5.2", 60 | "react-image": "^4.1.0", 61 | "react-redux": "^9.2.0", 62 | "react-router": "^7.6.2", 63 | "react-use-websocket": "^4.13.0", 64 | "reagraph": "^4.24.1", 65 | "store2": "^2.14.4", 66 | "tailwindcss": "^4.1.4", 67 | "timeago.js": "^4.0.2", 68 | "typescript": "^5.8.3", 69 | "vite": "^6.3.5", 70 | "vite-plugin-compression2": "^2.0.1", 71 | "vitest": "^3.1.2", 72 | "ws": "^8.18.2", 73 | "zigbee2mqtt": "^2.4.0" 74 | }, 75 | "sideEffects": [ 76 | "*.css" 77 | ], 78 | "files": [ 79 | "./index.js", 80 | "./index.d.ts", 81 | "./dist" 82 | ] 83 | } 84 | -------------------------------------------------------------------------------- /screenshots/device-exposes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nerivec/zigbee2mqtt-windfront/a2cda0465e362f664d7c0a63bbfa6a70d7a88ee4/screenshots/device-exposes.png -------------------------------------------------------------------------------- /screenshots/device-info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nerivec/zigbee2mqtt-windfront/a2cda0465e362f664d7c0a63bbfa6a70d7a88ee4/screenshots/device-info.png -------------------------------------------------------------------------------- /screenshots/devices-t1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nerivec/zigbee2mqtt-windfront/a2cda0465e362f664d7c0a63bbfa6a70d7a88ee4/screenshots/devices-t1.png -------------------------------------------------------------------------------- /screenshots/devices-t2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nerivec/zigbee2mqtt-windfront/a2cda0465e362f664d7c0a63bbfa6a70d7a88ee4/screenshots/devices-t2.png -------------------------------------------------------------------------------- /screenshots/devices-t3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nerivec/zigbee2mqtt-windfront/a2cda0465e362f664d7c0a63bbfa6a70d7a88ee4/screenshots/devices-t3.png -------------------------------------------------------------------------------- /screenshots/devices-t4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nerivec/zigbee2mqtt-windfront/a2cda0465e362f664d7c0a63bbfa6a70d7a88ee4/screenshots/devices-t4.png -------------------------------------------------------------------------------- /screenshots/network-data.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nerivec/zigbee2mqtt-windfront/a2cda0465e362f664d7c0a63bbfa6a70d7a88ee4/screenshots/network-data.png -------------------------------------------------------------------------------- /scripts/override-z2m-en.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "node:fs"; 2 | 3 | const SETTINGS_SCHEMA_URL = "https://github.com/Koenkk/zigbee2mqtt/raw/refs/heads/dev/lib/util/settings.schema.json"; 4 | const EN_LOCALE_PATH = "./src/i18n/locales/en.json"; 5 | 6 | const isObject = (value) => value !== null && typeof value === "object"; 7 | 8 | const enTranslations = JSON.parse(readFileSync(EN_LOCALE_PATH, "utf8")); 9 | const schema = await fetch(SETTINGS_SCHEMA_URL); 10 | const settingsSchemaDescriptions = {}; 11 | 12 | const exportSettingsSchemaDescriptions = (obj) => { 13 | for (const key in obj) { 14 | const value = obj[key]; 15 | 16 | if (isObject(value)) { 17 | exportSettingsSchemaDescriptions(value); 18 | } else if (key === "description") { 19 | settingsSchemaDescriptions[value] = value; 20 | } 21 | } 22 | }; 23 | 24 | exportSettingsSchemaDescriptions(await schema.json()); 25 | 26 | enTranslations.settingsSchemaDescriptions = settingsSchemaDescriptions; 27 | 28 | writeFileSync(EN_LOCALE_PATH, JSON.stringify(enTranslations, null, 4), "utf8"); 29 | console.log("Z2M override written to:", EN_LOCALE_PATH); 30 | -------------------------------------------------------------------------------- /scripts/override-zhc-en.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "node:fs"; 2 | import camelCase from "lodash/camelCase.js"; 3 | import startCase from "lodash/startCase.js"; 4 | import { prepareDefinition } from "zigbee-herdsman-converters"; 5 | import definitions from "zigbee-herdsman-converters/devices/index"; 6 | 7 | const EN_LOCALE_PATH = "./src/i18n/locales/en.json"; 8 | 9 | const enTranslations = JSON.parse(readFileSync(EN_LOCALE_PATH, "utf8")); 10 | const featureDescriptions = {}; 11 | const featureNames = {}; 12 | 13 | const exportDescriptions = ({ features = [], description, name }) => { 14 | featureNames[name] = startCase(camelCase(name)); 15 | featureDescriptions[description] = description; 16 | 17 | for (const feature of features) { 18 | exportDescriptions(feature); 19 | } 20 | }; 21 | 22 | for (const definition of definitions.default) { 23 | const d = prepareDefinition(definition); 24 | const exposes = typeof d.exposes === "function" ? d.exposes(undefined, undefined) : d.exposes; 25 | 26 | exportDescriptions({ features: exposes }); 27 | } 28 | 29 | enTranslations.featureDescriptions = featureDescriptions; 30 | enTranslations.featureNames = featureNames; 31 | 32 | writeFileSync(EN_LOCALE_PATH, JSON.stringify(enTranslations, null, 4), "utf8"); 33 | console.log("ZHC override written to:", EN_LOCALE_PATH); 34 | -------------------------------------------------------------------------------- /src/WebSocketApiRouter.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from "react"; 2 | import { WebSocketApiRouterContext } from "./WebSocketApiRouterContext.js"; 3 | import { useApiWebSocket } from "./hooks/useApiWebSocket.js"; 4 | 5 | export function WebSocketApiRouter({ children }: PropsWithChildren) { 6 | const webSocket = useApiWebSocket(); 7 | 8 | return {children}; 9 | } 10 | -------------------------------------------------------------------------------- /src/WebSocketApiRouterContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | import type { useApiWebSocket } from "./hooks/useApiWebSocket.js"; 3 | 4 | export const WebSocketApiRouterContext = createContext>({ 5 | sendMessage: async (_topic, _payload) => {}, 6 | readyState: -1, 7 | apiUrls: [], 8 | apiUrl: "", 9 | setApiUrl: (_value) => {}, 10 | }); 11 | -------------------------------------------------------------------------------- /src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import type { ButtonHTMLAttributes, JSX } from "react"; 2 | 3 | interface ButtonProps extends Omit, "onClick"> { 4 | item?: T; 5 | onClick?(entity: T): Promise | void; 6 | } 7 | 8 | export default function Button(props: ButtonProps): JSX.Element { 9 | const { children, item, onClick, ...rest } = props; 10 | 11 | return ( 12 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/components/ConfirmButton.tsx: -------------------------------------------------------------------------------- 1 | import NiceModal from "@ebay/nice-modal-react"; 2 | import { type ButtonHTMLAttributes, type JSX, useCallback } from "react"; 3 | import { DialogConfirmationModal, type DialogConfirmationModalProps } from "./modal/components/DialogConfirmationModal.js"; 4 | 5 | interface ConfirmButtonProps 6 | extends Omit, "onClick">, 7 | Omit { 8 | title: string; 9 | item?: T; 10 | onClick?(entity: T): Promise | void; 11 | } 12 | 13 | export default function ConfirmButton(props: ConfirmButtonProps): JSX.Element { 14 | const { children, item, onClick, modalDescription, modalCancelLabel, title, ...rest } = props; 15 | 16 | const onClickHandler = useCallback(async (): Promise => { 17 | await NiceModal.show(DialogConfirmationModal, { 18 | onConfirmHandler: async () => await onClick?.(item as T), 19 | modalTitle: title, 20 | modalDescription, 21 | modalCancelLabel, 22 | modalConfirmLabel: title, 23 | }); 24 | }, [item, onClick, title, modalDescription, modalCancelLabel]); 25 | 26 | return ( 27 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/components/PopoverDropdown.tsx: -------------------------------------------------------------------------------- 1 | import { type CSSProperties, type HTMLAttributes, type MouseEvent, type ReactElement, memo, useCallback } from "react"; 2 | import Button from "./Button.js"; 3 | 4 | interface PopoverDropdownProps extends HTMLAttributes { 5 | name: string; 6 | buttonChildren: ReactElement | string; 7 | buttonStyle?: string; 8 | dropdownStyle?: string; 9 | } 10 | 11 | const PopoverDropdown = memo((props: PopoverDropdownProps) => { 12 | const { buttonChildren, children, name, buttonStyle, dropdownStyle } = props; 13 | const popoverId = `popover-${name}`; 14 | const anchorName = `--anchor-${name}`; 15 | 16 | const onPopoverClick = useCallback((event: MouseEvent) => { 17 | if ((event.target as HTMLElement).tagName !== "INPUT") { 18 | event.currentTarget.togglePopover(false); 19 | } 20 | }, []); 21 | 22 | return ( 23 | <> 24 |
    31 | {children} 32 |
33 | 40 | 41 | ); 42 | }); 43 | 44 | export default PopoverDropdown; 45 | -------------------------------------------------------------------------------- /src/components/ScrollToTop.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useLocation } from "react-router"; 3 | 4 | const ScrollToTop = () => { 5 | const { pathname } = useLocation(); 6 | 7 | // biome-ignore lint/correctness/useExhaustiveDependencies: specific trigger 8 | useEffect(() => { 9 | window.scrollTo(0, 0); 10 | }, [pathname]); 11 | 12 | return null; 13 | }; 14 | 15 | export default ScrollToTop; 16 | -------------------------------------------------------------------------------- /src/components/ThemeSwitcher.tsx: -------------------------------------------------------------------------------- 1 | import { faPaintBrush } from "@fortawesome/free-solid-svg-icons"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import { memo, useEffect, useState } from "react"; 4 | import store2 from "store2"; 5 | import { THEME_KEY } from "../localStoreConsts.js"; 6 | import PopoverDropdown from "./PopoverDropdown.js"; 7 | 8 | const ALL_THEMES = [ 9 | "", // "Default" 10 | "Light", 11 | "Dark", 12 | "Abyss", 13 | "Acid", 14 | "Aqua", 15 | "Autumn", 16 | "Black", 17 | "Bumblebee", 18 | "Business", 19 | "Caramellatte", 20 | "Cmyk", 21 | "Coffee", 22 | "Corporate", 23 | "Cupcake", 24 | "Cyberpunk", 25 | "Dim", 26 | "Dracula", 27 | "Emerald", 28 | "Fantasy", 29 | "Forest", 30 | "Garden", 31 | "Halloween", 32 | "Lemonade", 33 | "Lofi", 34 | "Luxury", 35 | "Night", 36 | "Nord", 37 | "Pastel", 38 | "Retro", 39 | "Silk", 40 | "Sunset", 41 | "Synthwave", 42 | "Valentine", 43 | "Winter", 44 | "Wireframe", 45 | ]; 46 | 47 | const ThemeSwitcher = memo(() => { 48 | const [currentTheme, setCurrentTheme] = useState(store2.get(THEME_KEY, "")); 49 | 50 | useEffect(() => { 51 | store2.set(THEME_KEY, currentTheme); 52 | document.documentElement.setAttribute("data-theme", currentTheme); 53 | }, [currentTheme]); 54 | 55 | return ( 56 | } 59 | buttonStyle="mx-1" 60 | dropdownStyle="dropdown-end" 61 | > 62 | {ALL_THEMES.map((theme) => ( 63 |
  • 64 | setCurrentTheme(theme.toLowerCase())} 71 | /> 72 |
  • 73 | ))} 74 |
    75 | ); 76 | }); 77 | 78 | export default ThemeSwitcher; 79 | -------------------------------------------------------------------------------- /src/components/dashboard-page/DashboardFeatureWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 2 | import camelCase from "lodash/camelCase.js"; 3 | import startCase from "lodash/startCase.js"; 4 | import { type PropsWithChildren, memo, useMemo } from "react"; 5 | import { useTranslation } from "react-i18next"; 6 | import type { FeatureWrapperProps } from "../features/FeatureWrapper.js"; 7 | import { getFeatureIcon } from "../features/index.js"; 8 | 9 | const DashboardFeatureWrapper = memo((props: PropsWithChildren) => { 10 | const { children, feature, deviceValue } = props; 11 | // @ts-expect-error `undefined` is fine 12 | const unit = feature.unit as string | undefined; 13 | const fi = useMemo(() => getFeatureIcon(feature.name, deviceValue, unit), [unit, feature.name, deviceValue]); 14 | const { t } = useTranslation(["featureNames", "zigbee"]); 15 | const featureName = feature.name === "state" ? feature.property : feature.name; 16 | const fallbackFeatureName = startCase(camelCase(featureName)); 17 | 18 | return ( 19 |
    20 | 21 |
    22 | {t(featureName!, { defaultValue: fallbackFeatureName })} 23 | {feature.endpoint ? ` (${feature.endpoint})` : null} 24 |
    25 |
    {children}
    26 |
    27 | ); 28 | }); 29 | 30 | export default DashboardFeatureWrapper; 31 | -------------------------------------------------------------------------------- /src/components/dashboard-page/index.tsx: -------------------------------------------------------------------------------- 1 | import type { AnySubFeature, BasicFeature, DeviceState, FeatureAccessMode, FeatureWithAnySubFeatures, FeatureWithSubFeatures } from "../../types.js"; 2 | 3 | const BLACKLISTED_PARTIAL_FEATURE_NAMES = ["schedule_", "_mode", "_options", "_startup", "_type", "inching_"]; 4 | 5 | const BLACKLISTED_FEATURE_NAMES = [ 6 | "battery", 7 | "linkquality", 8 | "options", 9 | "position", 10 | "programming", 11 | "strength", 12 | "voltage", 13 | "warning", 14 | "gradient", 15 | "power_outage_memory", 16 | "power_on_behavior", 17 | ]; 18 | 19 | const WHITELISTED_FEATURE_NAMES = ["state", "brightness", "color_temp", "mode", "sound", "occupancy", "tamper", "alarm", "action", "contact"]; 20 | 21 | const isValid = (name: string | undefined, _access: FeatureAccessMode): boolean => { 22 | if (name) { 23 | if (WHITELISTED_FEATURE_NAMES.includes(name)) { 24 | return true; 25 | } 26 | 27 | for (const bName of BLACKLISTED_PARTIAL_FEATURE_NAMES) { 28 | if (name.includes(bName)) { 29 | return false; 30 | } 31 | } 32 | 33 | if (BLACKLISTED_FEATURE_NAMES.includes(name)) { 34 | return false; 35 | } 36 | } 37 | 38 | return true; 39 | }; 40 | 41 | export const getDashboardFeatures = ( 42 | feature: BasicFeature | FeatureWithSubFeatures, 43 | deviceState: DeviceState = {}, 44 | ): FeatureWithAnySubFeatures | undefined => { 45 | const { property, name, access } = feature; 46 | 47 | if ("features" in feature && feature.features && feature.features.length > 0) { 48 | const features: AnySubFeature[] = []; 49 | const state = property ? (deviceState[property] as DeviceState) : deviceState; 50 | 51 | for (const subFeature of feature.features) { 52 | const validFeature = getDashboardFeatures(subFeature, state); 53 | 54 | if (validFeature && !features.some((f) => f.property === validFeature.property)) { 55 | features.push(validFeature); 56 | } 57 | } 58 | 59 | return features.length > 0 || isValid(name, access) ? { ...feature, features } : undefined; 60 | } 61 | 62 | return isValid(name, access) ? { ...feature } : undefined; 63 | }; 64 | -------------------------------------------------------------------------------- /src/components/device-page/ExternalDefinition.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useCallback, useContext, useState } from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | import { Link } from "react-router"; 4 | import { WebSocketApiRouterContext } from "../../WebSocketApiRouterContext.js"; 5 | import { SUPPORT_NEW_DEVICES_DOCS_URL } from "../../consts.js"; 6 | import { useAppSelector } from "../../hooks/useApp.js"; 7 | import type { Device } from "../../types.js"; 8 | import Button from "../Button.js"; 9 | import TextareaField from "../form-fields/TextareaField.js"; 10 | 11 | export interface ExternalDefinitionProps { 12 | device: Device; 13 | } 14 | 15 | const ExternalDefinition = memo((props: ExternalDefinitionProps) => { 16 | const { device } = props; 17 | const [loading, setLoading] = useState(false); 18 | const { sendMessage } = useContext(WebSocketApiRouterContext); 19 | const { t } = useTranslation(["devConsole", "common"]); 20 | const externalDefinition = useAppSelector((state) => state.generatedExternalDefinitions[device.ieee_address]); 21 | 22 | const onGenerateExternalDefinitionClick = useCallback(async (): Promise => { 23 | setLoading(true); 24 | await sendMessage("bridge/request/device/generate_external_definition", { id: device.ieee_address }); 25 | }, [sendMessage, device.ieee_address]); 26 | 27 | return externalDefinition ? ( 28 | <> 29 | 37 | 38 | {t("common:documentation")} 39 | 40 | 41 | ) : ( 42 |
    43 | {loading ? ( 44 | 45 | ) : ( 46 | className="btn btn-primary" onClick={onGenerateExternalDefinitionClick}> 47 | {t("generate_external_definition")} 48 | 49 | )} 50 |
    51 | ); 52 | }); 53 | 54 | export default ExternalDefinition; 55 | -------------------------------------------------------------------------------- /src/components/device-page/HeaderDeviceSelector.tsx: -------------------------------------------------------------------------------- 1 | import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import { memo, useMemo, useState } from "react"; 4 | import { useTranslation } from "react-i18next"; 5 | import { Link } from "react-router"; 6 | import type { TabName } from "../../pages/DevicePage.js"; 7 | import type { RootState } from "../../store.js"; 8 | import type { Device } from "../../types.js"; 9 | import PopoverDropdown from "../PopoverDropdown.js"; 10 | 11 | interface HeaderDeviceSelectorProps { 12 | devices: RootState["devices"]; 13 | currentDevice: Device | undefined; 14 | tab?: TabName; 15 | } 16 | 17 | const HeaderDeviceSelector = memo((props: HeaderDeviceSelectorProps) => { 18 | const { devices, currentDevice, tab = "info" } = props; 19 | const [searchTerm, setSearchTerm] = useState(""); 20 | const { t } = useTranslation("common"); 21 | const items = useMemo( 22 | () => 23 | devices 24 | .filter( 25 | (device) => 26 | device.type !== "Coordinator" && 27 | device.friendly_name.toLowerCase().includes(searchTerm.toLowerCase()) && 28 | device.ieee_address !== currentDevice?.ieee_address, 29 | ) 30 | .map((device) => ( 31 |
  • 32 | setSearchTerm("")} className="dropdown-item"> 33 | {device.friendly_name} 34 | 35 |
  • 36 | )), 37 | [devices, currentDevice, searchTerm, tab], 38 | ); 39 | 40 | return ( 41 | 46 | 50 | {items} 51 | 52 | ); 53 | }); 54 | 55 | export default HeaderDeviceSelector; 56 | -------------------------------------------------------------------------------- /src/components/device-page/LastLogResult.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | import { LOG_LEVELS_CMAP } from "../../consts.js"; 3 | import type { LogMessage } from "../../types.js"; 4 | 5 | export type LastLogResultProps = { 6 | message: LogMessage; 7 | }; 8 | 9 | const LastLogResult = memo((props: LastLogResultProps) => { 10 | const { message } = props; 11 | 12 | return ( 13 |
    14 |
    15 |                 
    16 |                     [{message.timestamp}] {message.message}
    17 |                 
    18 |             
    19 |
    20 | ); 21 | }); 22 | 23 | export default LastLogResult; 24 | -------------------------------------------------------------------------------- /src/components/device-page/ScenePicker.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | import type { Scene } from "../../types.js"; 4 | import InputField from "../form-fields/InputField.js"; 5 | import SelectField from "../form-fields/SelectField.js"; 6 | 7 | type ScenePickerProps = { 8 | value?: Scene; 9 | scenes: Scene[]; 10 | onSceneSelected: (sceneId: number) => void; 11 | }; 12 | 13 | const ScenePicker = memo((props: ScenePickerProps) => { 14 | const { t } = useTranslation("scene"); 15 | const { onSceneSelected, scenes = [], value } = props; 16 | 17 | return scenes.length > 0 ? ( 18 | !e.target.validationMessage && !!e.target.value && onSceneSelected(Number.parseInt(e.target.value, 10))} 23 | > 24 | 27 | {scenes.map((scene) => ( 28 | 31 | ))} 32 | 33 | ) : ( 34 | !e.target.validationMessage && !!e.target.value && onSceneSelected(e.target.valueAsNumber)} 40 | min={0} 41 | max={255} 42 | /> 43 | ); 44 | }); 45 | 46 | export default ScenePicker; 47 | -------------------------------------------------------------------------------- /src/components/device-page/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type AnySubFeature, 3 | type BasicFeature, 4 | type Device, 5 | type DeviceState, 6 | FeatureAccessMode, 7 | type FeatureWithAnySubFeatures, 8 | type FeatureWithSubFeatures, 9 | type Group, 10 | type Scene, 11 | } from "../../types.js"; 12 | 13 | import { isDevice } from "../../utils.js"; 14 | 15 | export function getScenes(target: Group | Device): Scene[] { 16 | if (isDevice(target)) { 17 | const scenes: Scene[] = []; 18 | 19 | for (const key in target.endpoints) { 20 | const ep = target.endpoints[key]; 21 | 22 | for (const scene of ep.scenes) { 23 | scenes.push({ ...scene }); 24 | } 25 | } 26 | 27 | return scenes; 28 | } 29 | 30 | return target.scenes; 31 | } 32 | 33 | const BLACKLISTED_PARTIAL_FEATURE_NAMES = ["schedule_", "_mode", "_options", "_startup", "_type", "inching_"]; 34 | 35 | const BLACKLISTED_FEATURE_NAMES = ["effect"]; 36 | 37 | const WHITELIST_FEATURE_NAMES = ["state", "color_temp", "color", "transition", "brightness"]; 38 | 39 | const isValid = (name: string | undefined, access: FeatureAccessMode): boolean => { 40 | if (name) { 41 | if (WHITELIST_FEATURE_NAMES.includes(name)) { 42 | return true; 43 | } 44 | 45 | for (const bName of BLACKLISTED_PARTIAL_FEATURE_NAMES) { 46 | if (name.includes(bName)) { 47 | return false; 48 | } 49 | } 50 | 51 | if (BLACKLISTED_FEATURE_NAMES.includes(name)) { 52 | return false; 53 | } 54 | } 55 | 56 | if (access === FeatureAccessMode.ALL || access === FeatureAccessMode.SET || access === FeatureAccessMode.STATE_SET) { 57 | return true; 58 | } 59 | 60 | return false; 61 | }; 62 | 63 | export function getScenesFeatures( 64 | feature: BasicFeature | FeatureWithSubFeatures, 65 | deviceState: DeviceState = {} as DeviceState, 66 | ): FeatureWithAnySubFeatures | undefined { 67 | const { property, name, access } = feature; 68 | 69 | if ("features" in feature && feature.features && feature.features.length > 0) { 70 | const features: AnySubFeature[] = []; 71 | const state = property ? (deviceState[property] as DeviceState) : deviceState; 72 | 73 | for (const subFeature of feature.features) { 74 | const validFeature = getScenesFeatures(subFeature, state); 75 | 76 | if (validFeature && !features.some((f) => f.property === validFeature.property)) { 77 | features.push(validFeature); 78 | } 79 | } 80 | 81 | return features.length > 0 || isValid(name, access) ? { ...feature, features } : undefined; 82 | } 83 | 84 | return isValid(name, access) ? { ...feature } : undefined; 85 | } 86 | -------------------------------------------------------------------------------- /src/components/device-page/tabs/Bind.tsx: -------------------------------------------------------------------------------- 1 | import { type JSX, useEffect, useMemo, useState } from "react"; 2 | import { useAppSelector } from "../../../hooks/useApp.js"; 3 | import type { Device } from "../../../types.js"; 4 | import BindRow from "../BindRow.js"; 5 | 6 | interface BindProps { 7 | device: Device; 8 | } 9 | 10 | export interface NiceBindingRule { 11 | id?: number; 12 | isNew?: true; 13 | source: { 14 | ieee_address: string; 15 | endpoint: string | number; 16 | }; 17 | target: 18 | | { 19 | type: "group"; 20 | id: number; 21 | } 22 | | { 23 | type: "endpoint"; 24 | endpoint: string | number; 25 | ieee_address: string; 26 | }; 27 | clusters: string[]; 28 | } 29 | 30 | const getRuleKey = (rule: NiceBindingRule): string => 31 | `${rule.isNew}-${rule.source.endpoint}-${rule.source.ieee_address}-${"ieee_address" in rule.target ? rule.target.ieee_address : rule.target.id}-${rule.clusters.join("-")}`; 32 | 33 | const convertBindingsIntoNiceStructure = (device: Device): NiceBindingRule[] => { 34 | const bindings: Record = {}; 35 | 36 | for (const endpoint in device.endpoints) { 37 | const endpointDesc = device.endpoints[endpoint]; 38 | 39 | for (const binding of endpointDesc.bindings) { 40 | let targetId = "ieee_address" in binding.target ? `${binding.target.ieee_address}-${binding.target.endpoint}` : binding.target.id; 41 | 42 | targetId = `${targetId}-${endpoint}`; 43 | 44 | if (bindings[targetId]) { 45 | bindings[targetId].clusters.push(binding.cluster); 46 | } else { 47 | bindings[targetId] = { 48 | source: { 49 | ieee_address: device.ieee_address, 50 | endpoint, 51 | }, 52 | target: binding.target, 53 | clusters: [binding.cluster], 54 | }; 55 | } 56 | } 57 | } 58 | 59 | return Object.values(bindings); 60 | }; 61 | 62 | export default function Bind(props: BindProps): JSX.Element { 63 | const { device } = props; 64 | const devices = useAppSelector((state) => state.devices); 65 | const groups = useAppSelector((state) => state.groups); 66 | const [newBindingRule, setNewBindingRule] = useState({ 67 | isNew: true, 68 | target: { type: "endpoint", ieee_address: "", endpoint: "" }, 69 | source: { ieee_address: device.ieee_address, endpoint: "" }, 70 | clusters: [], 71 | }); 72 | const bindingRules = useMemo(() => convertBindingsIntoNiceStructure(device), [device]); 73 | 74 | useEffect(() => { 75 | // force reset of new rule when swapping device, otherwise might end up applying with wrong params 76 | setNewBindingRule({ 77 | isNew: true, 78 | target: { type: "endpoint", ieee_address: "", endpoint: "" }, 79 | source: { ieee_address: device.ieee_address, endpoint: "" }, 80 | clusters: [], 81 | }); 82 | }, [device.ieee_address]); 83 | 84 | return ( 85 |
    86 | {[...bindingRules, newBindingRule].map((rule) => ( 87 | 88 | ))} 89 |
    90 | ); 91 | } 92 | -------------------------------------------------------------------------------- /src/components/device-page/tabs/Clusters.tsx: -------------------------------------------------------------------------------- 1 | import { faArrowCircleLeft, faArrowCircleRight } from "@fortawesome/free-solid-svg-icons"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import { type JSX, useMemo } from "react"; 4 | import { useTranslation } from "react-i18next"; 5 | import type { Device } from "../../../types.js"; 6 | 7 | interface ClustersProps { 8 | device: Device; 9 | } 10 | 11 | export default function Clusters({ device }: ClustersProps) { 12 | const { t } = useTranslation("zigbee"); 13 | 14 | const clustersByEndpoint = useMemo(() => { 15 | const clusters: JSX.Element[] = []; 16 | 17 | for (const endpointId in device.endpoints) { 18 | const endpoint = device.endpoints[endpointId]; 19 | 20 | clusters.push( 21 |
  • 22 |
    23 | 24 | {t("endpoint")} {endpointId} 25 | 26 |
      27 |
    • 28 |
      29 | 30 | {t("output_clusters")} 31 | 32 |
        33 | {endpoint.clusters.output.map((cluster) => ( 34 |
      • 35 | {cluster} 36 |
      • 37 | ))} 38 |
      39 |
      40 |
    • 41 |
    • 42 |
      43 | 44 | {t("input_clusters")} 45 | 46 |
        47 | {endpoint.clusters.input.map((cluster) => ( 48 |
      • 49 | {cluster} 50 |
      • 51 | ))} 52 |
      53 |
      54 |
    • 55 |
    56 |
    57 |
  • , 58 | ); 59 | } 60 | 61 | return clusters; 62 | }, [device.endpoints, t]); 63 | 64 | return
      {clustersByEndpoint}
    ; 65 | } 66 | -------------------------------------------------------------------------------- /src/components/device-page/tabs/DeviceSettings.tsx: -------------------------------------------------------------------------------- 1 | import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import type { JSONSchema7 } from "json-schema"; 4 | import merge from "lodash/merge.js"; 5 | import { useCallback, useContext } from "react"; 6 | import { useTranslation } from "react-i18next"; 7 | import { WebSocketApiRouterContext } from "../../../WebSocketApiRouterContext.js"; 8 | import { DEVICE_OPTIONS_DOCS_URL } from "../../../consts.js"; 9 | import { useAppSelector } from "../../../hooks/useApp.js"; 10 | import type { Device } from "../../../types.js"; 11 | import SettingsList from "../../json-schema/SettingsList.js"; 12 | 13 | interface DeviceSettingsProps { 14 | device: Device; 15 | } 16 | 17 | export default function DeviceSettings({ device }: DeviceSettingsProps) { 18 | const { t } = useTranslation(["settings", "common"]); 19 | const bridgeInfo = useAppSelector((state) => state.bridgeInfo); 20 | const { sendMessage } = useContext(WebSocketApiRouterContext); 21 | 22 | const setDeviceOptions = useCallback( 23 | async (options: Record) => { 24 | await sendMessage("bridge/request/device/options", { id: device.ieee_address, options }); 25 | }, 26 | [sendMessage, device.ieee_address], 27 | ); 28 | 29 | return ( 30 | <> 31 | 37 | 42 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/components/device-page/tabs/DeviceSpecificSettings.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useContext } from "react"; 2 | import type { Device } from "../../../types.js"; 3 | 4 | import { useTranslation } from "react-i18next"; 5 | import { WebSocketApiRouterContext } from "../../../WebSocketApiRouterContext.js"; 6 | import { useAppSelector } from "../../../hooks/useApp.js"; 7 | import Feature from "../../features/Feature.js"; 8 | import FeatureWrapper from "../../features/FeatureWrapper.js"; 9 | import { getFeatureKey } from "../../features/index.js"; 10 | 11 | type DeviceSpecificSettingsProps = { 12 | device: Device; 13 | }; 14 | 15 | export default function DeviceSpecificSettings({ device }: DeviceSpecificSettingsProps) { 16 | const { t } = useTranslation(["exposes"]); 17 | const bridgeInfo = useAppSelector((state) => state.bridgeInfo); 18 | const { sendMessage } = useContext(WebSocketApiRouterContext); 19 | const setDeviceOptions = useCallback( 20 | async (options: Record) => { 21 | await sendMessage("bridge/request/device/options", { id: device.ieee_address, options }); 22 | }, 23 | [sendMessage, device.ieee_address], 24 | ); 25 | 26 | return device.definition?.options?.length ? ( 27 |
    28 | {device.definition.options.map((option) => ( 29 | 38 | ))} 39 |
    40 | ) : ( 41 | t("empty_exposes_definition") 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/components/device-page/tabs/Exposes.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useContext } from "react"; 2 | import type { Device } from "../../../types.js"; 3 | 4 | import { useTranslation } from "react-i18next"; 5 | import { WebSocketApiRouterContext } from "../../../WebSocketApiRouterContext.js"; 6 | import { useAppSelector } from "../../../hooks/useApp.js"; 7 | import Feature from "../../features/Feature.js"; 8 | import FeatureWrapper from "../../features/FeatureWrapper.js"; 9 | import { getFeatureKey } from "../../features/index.js"; 10 | 11 | type ExposesProps = { 12 | device: Device; 13 | }; 14 | 15 | export default function Exposes(props: ExposesProps) { 16 | const { device } = props; 17 | const { t } = useTranslation(["exposes"]); 18 | const { sendMessage } = useContext(WebSocketApiRouterContext); 19 | const deviceState = useAppSelector((state) => state.deviceStates[device.friendly_name] ?? {}); 20 | 21 | const onChange = useCallback( 22 | async (value: Record) => { 23 | await sendMessage<"{friendlyNameOrId}/set">( 24 | // @ts-expect-error templated API endpoint 25 | `${device.ieee_address}/set`, 26 | value, 27 | ); 28 | }, 29 | [sendMessage, device.ieee_address], 30 | ); 31 | 32 | const onRead = useCallback( 33 | async (value: Record) => { 34 | await sendMessage<"{friendlyNameOrId}/get">( 35 | // @ts-expect-error templated API endpoint 36 | `${device.ieee_address}/get`, 37 | value, 38 | ); 39 | }, 40 | [sendMessage, device.ieee_address], 41 | ); 42 | 43 | return device.definition?.exposes?.length ? ( 44 |
    45 | {device.definition.exposes.map((expose) => ( 46 | 56 | ))} 57 |
    58 | ) : ( 59 | t("empty_exposes_definition") 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /src/components/device-page/tabs/Reporting.tsx: -------------------------------------------------------------------------------- 1 | import { type JSX, useCallback, useContext, useEffect, useMemo, useState } from "react"; 2 | import type { Device } from "../../../types.js"; 3 | 4 | import { WebSocketApiRouterContext } from "../../../WebSocketApiRouterContext.js"; 5 | import ReportingRow from "../ReportingRow.js"; 6 | 7 | interface ReportingProps { 8 | device: Device; 9 | } 10 | 11 | export type NiceReportingRule = { 12 | isNew?: string; 13 | endpoint: string; 14 | } & Device["endpoints"][number]["configured_reportings"][number]; 15 | 16 | const convertBindingsIntoNiceStructure = (device: Device): NiceReportingRule[] => { 17 | const niceReportingRules: NiceReportingRule[] = []; 18 | 19 | for (const key in device.endpoints) { 20 | const endpoint = device.endpoints[key]; 21 | 22 | for (const cr of endpoint.configured_reportings) { 23 | niceReportingRules.push({ 24 | ...cr, 25 | endpoint: key, 26 | }); 27 | } 28 | } 29 | 30 | return niceReportingRules; 31 | }; 32 | 33 | const getRuleKey = (rule: NiceReportingRule): string => `${rule.isNew}-${rule.endpoint}-${rule.cluster}-${rule.attribute}`; 34 | 35 | export default function Reporting(props: ReportingProps): JSX.Element { 36 | const { device } = props; 37 | const { sendMessage } = useContext(WebSocketApiRouterContext); 38 | const [newReportingRule, setNewReportingRule] = useState({ 39 | isNew: device.ieee_address, 40 | reportable_change: 0, 41 | minimum_report_interval: 60, 42 | maximum_report_interval: 3600, 43 | endpoint: "", 44 | cluster: "", 45 | attribute: "", 46 | }); 47 | const reportingRules = useMemo(() => convertBindingsIntoNiceStructure(device), [device]); 48 | 49 | useEffect(() => { 50 | // force reset of new rule when swapping device, otherwise might end up applying with wrong params 51 | setNewReportingRule({ 52 | isNew: device.ieee_address, 53 | reportable_change: 0, 54 | minimum_report_interval: 60, 55 | maximum_report_interval: 3600, 56 | endpoint: "", 57 | cluster: "", 58 | attribute: "", 59 | }); 60 | }, [device.ieee_address]); 61 | 62 | const onApply = useCallback( 63 | async (rule: NiceReportingRule): Promise => { 64 | const { cluster, endpoint, attribute, minimum_report_interval, maximum_report_interval, reportable_change } = rule; 65 | 66 | await sendMessage("bridge/request/device/configure_reporting", { 67 | id: device.ieee_address, 68 | endpoint, 69 | cluster, 70 | attribute, 71 | minimum_report_interval, 72 | maximum_report_interval, 73 | reportable_change, 74 | option: {}, // TODO: check this 75 | }); 76 | }, 77 | [sendMessage, device.ieee_address], 78 | ); 79 | 80 | return ( 81 |
    82 | {[...reportingRules, newReportingRule].map((rule) => ( 83 | 84 | ))} 85 |
    86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /src/components/device-page/tabs/Scene.tsx: -------------------------------------------------------------------------------- 1 | import { useAppSelector } from "../../../hooks/useApp.js"; 2 | import type { Device } from "../../../types.js"; 3 | import AddScene from "../AddScene.js"; 4 | import RecallRemove from "../RecallRemove.js"; 5 | 6 | type SceneProps = { 7 | device: Device; 8 | }; 9 | 10 | export default function Scene(props: SceneProps) { 11 | const deviceState = useAppSelector((state) => state.deviceStates[props.device.friendly_name] ?? {}); 12 | 13 | return ( 14 |
    15 |
    16 | 17 |
    18 |
    19 | 20 |
    21 |
    22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/components/device-page/tabs/State.tsx: -------------------------------------------------------------------------------- 1 | import { useAppSelector } from "../../../hooks/useApp.js"; 2 | import type { Device } from "../../../types.js"; 3 | 4 | type StatesProps = { device: Device }; 5 | 6 | export default function State(props: StatesProps) { 7 | const { device } = props; 8 | const deviceState = useAppSelector((state) => state.deviceStates[device.friendly_name] ?? {}); 9 | const jsonState = JSON.stringify(deviceState, null, 4); 10 | const lines = Math.max(10, (jsonState.match(/\n/g) || "").length + 1); 11 | 12 | return