├── .browserslistrc ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .dockerignore ├── .github ├── FUNDING.yml ├── dependabot.yml ├── release-drafter.yml └── workflows │ ├── ReleaseActions.yml │ ├── ci.yml │ └── release-drafter.yml ├── .gitignore ├── .gitmodules ├── .husky └── pre-commit ├── .nvmrc ├── .prettierrc ├── .yarnrc.yml ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── VERSION ├── build-scripts ├── .eslintrc ├── README.md ├── babel-plugins │ ├── custom-polyfill-plugin.js │ └── inline-constants-plugin.cjs ├── bundle.cjs ├── env.cjs ├── eslint.config.mjs ├── gulp │ ├── clean.js │ ├── compress.js │ ├── entry-html.js │ ├── fetch-nightly-translations.js │ ├── gen-icons-json.js │ ├── index.mjs │ ├── knx.js │ ├── locale-data.js │ ├── rspack.js │ └── translations.js ├── paths.cjs ├── removedIcons.json └── rspack.cjs ├── config.js ├── eslint.config.mjs ├── gulpfile.js ├── package.json ├── pyproject.toml ├── rspack.config.cjs ├── screenshots ├── bus_monitor.png ├── info.png └── project.png ├── script ├── bootstrap ├── build ├── develop ├── merge_requirements.js └── upgrade-frontend ├── setup.cfg ├── src ├── __init__.py ├── components │ ├── knx-configure-entity-options.ts │ ├── knx-configure-entity.ts │ ├── knx-device-picker.ts │ ├── knx-dpt-selector.ts │ ├── knx-group-address-selector.ts │ ├── knx-project-device-tree.ts │ ├── knx-project-tree-view.ts │ ├── knx-selector-row.ts │ └── knx-sync-state-selector-row.ts ├── constants.py ├── dialogs │ ├── knx-device-create-dialog.ts │ └── knx-telegram-info-dialog.ts ├── entrypoint.ts ├── knx-router.ts ├── knx.ts ├── localize │ ├── languages │ │ ├── de.json │ │ └── en.json │ └── localize.ts ├── main.ts ├── py.typed ├── services │ └── websocket.service.ts ├── tools │ └── knx-logger.ts ├── types │ ├── entity_data.ts │ ├── knx.ts │ ├── navigation.ts │ └── websocket.ts ├── utils │ ├── common.ts │ ├── device.ts │ ├── dpt.ts │ ├── drag-drop-context.ts │ ├── format.ts │ ├── schema.ts │ └── validation.ts ├── version.ts └── views │ ├── entities_create.ts │ ├── entities_router.ts │ ├── entities_view.ts │ ├── error.ts │ ├── group_monitor.ts │ ├── info.ts │ └── project_view.ts ├── tsconfig.json └── yarn.lock /.browserslistrc: -------------------------------------------------------------------------------- 1 | [modern] 2 | # Modern builds target recent browsers supporting the latest features to minimize transpilation, polyfills, etc. 3 | # It is served to browsers meeting the following requirements: 4 | # - released in the last year + current alpha/beta versions 5 | # - Firefox extended support release (ESR) 6 | # - with global utilization at or above 0.5% 7 | # - exclude dead browsers (no security maintenance for 2+ years) 8 | # - exclude KaiOS, QQ, and UC browsers due to lack of sufficient feature support data 9 | unreleased versions 10 | last 1 year 11 | Firefox ESR 12 | >= 0.5% 13 | not dead 14 | not KaiOS > 0 15 | not QQAndroid > 0 16 | not UCAndroid > 0 17 | 18 | [legacy] 19 | # Legacy builds are served when modern requirements are not met and support browsers: 20 | # - released in the last 7 years + current alpha/beta versionss 21 | # - with global utilization at or above 0.05% 22 | # - exclude dead browsers (no security maintenance for 2+ years) 23 | # - exclude Opera Mini which does not support web sockets 24 | unreleased versions 25 | last 7 years 26 | >= 0.05% 27 | not dead 28 | not op_mini all 29 | 30 | [legacy-sw] 31 | # Same as legacy plus supports service workers 32 | unreleased versions 33 | last 7 years 34 | >= 0.05% and supports serviceworkers 35 | not dead 36 | not op_mini all 37 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.148.1/containers/python-3/.devcontainer/base.Dockerfile 2 | FROM mcr.microsoft.com/devcontainers/python:1-3.13 3 | 4 | ENV \ 5 | DEBIAN_FRONTEND=noninteractive \ 6 | DEVCONTAINER=true \ 7 | PATH=$PATH:./node_modules/.bin 8 | 9 | # Install nvm 10 | COPY .nvmrc /tmp/.nvmrc 11 | RUN \ 12 | su vscode -c \ 13 | "source /usr/local/share/nvm/nvm.sh && nvm install $(cat /tmp/.nvmrc) 2>&1" -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "KNX Panel for Home Assistant", 3 | "build": { 4 | "dockerfile": "Dockerfile", 5 | "context": ".." 6 | }, 7 | "postCreateCommand": "./homeassistant-frontend/.devcontainer/post_create.sh", 8 | "postStartCommand": "script/bootstrap", 9 | "containerEnv": { 10 | "DEV_CONTAINER": "1", 11 | "WORKSPACE_DIRECTORY": "${containerWorkspaceFolder}" 12 | }, 13 | "remoteEnv": { 14 | "NODE_OPTIONS": "--max_old_space_size=8192" 15 | }, 16 | "customizations": { 17 | "vscode": { 18 | "extensions": [ 19 | "github.vscode-pull-request-github", 20 | "dbaeumer.vscode-eslint", 21 | "ms-vscode.vscode-typescript-tslint-plugin", 22 | "esbenp.prettier-vscode", 23 | "bierner.lit-html", 24 | "runem.lit-plugin", 25 | "ms-python.vscode-pylance" 26 | ], 27 | "settings": { 28 | "terminal.integrated.shell.linux": "/bin/bash", 29 | "files.eol": "\n", 30 | "editor.tabSize": 2, 31 | "editor.formatOnPaste": false, 32 | "editor.formatOnSave": true, 33 | "editor.formatOnType": true, 34 | "[typescript]": { 35 | "editor.defaultFormatter": "esbenp.prettier-vscode" 36 | }, 37 | "[javascript]": { 38 | "editor.defaultFormatter": "esbenp.prettier-vscode" 39 | }, 40 | "files.trimTrailingWhitespace": true 41 | } 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: XKNX 2 | open_collective: xknx 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | categories: 2 | - title: '⬆️ Dependencies' 3 | collapse-after: 3 4 | labels: 5 | - 'dependencies' 6 | template: | 7 | ## What’s Changed 8 | 9 | $CHANGES 10 | -------------------------------------------------------------------------------- /.github/workflows/ReleaseActions.yml: -------------------------------------------------------------------------------- 1 | name: "Release actions" 2 | 3 | on: 4 | release: 5 | types: ["published"] 6 | 7 | env: 8 | PYTHON_VERSION: "3.x" 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | name: Deploy to PyPi 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | submodules: recursive 18 | 19 | - name: Set up Python 20 | uses: actions/setup-python@v5.6.0 21 | with: 22 | python-version: ${{ env.PYTHON_VERSION }} 23 | - name: Setup Node 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version-file: ".nvmrc" 27 | cache: yarn 28 | - name: "Set version number from tag" 29 | run: | 30 | echo -n '${{ github.ref_name }}' > ./VERSION 31 | sed -i 's/dev/${{ github.ref_name }}/g' ./src/version.ts 32 | cat ./VERSION 33 | cat ./src/version.ts 34 | 35 | - name: Install dependencies 36 | run: | 37 | python -m pip install build twine 38 | ./script/bootstrap 39 | 40 | - name: Build 41 | run: yarn build 42 | 43 | - name: Publish to PyPi 44 | env: 45 | TWINE_USERNAME: __token__ 46 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 47 | run: | 48 | python -m build 49 | twine upload dist/* 50 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: "CI" 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | lint: 13 | name: Lint 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Check out files from GitHub 17 | uses: actions/checkout@v4 18 | with: 19 | submodules: recursive 20 | - name: Setup Node 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version-file: ".nvmrc" 24 | cache: yarn 25 | - name: Install dependencies 26 | run: script/bootstrap 27 | 28 | - name: Run eslint 29 | run: yarn run lint:eslint 30 | 31 | - name: Run prettier 32 | run: yarn run lint:prettier 33 | 34 | - name: Check for duplicate dependencies 35 | run: yarn dedupe --check 36 | 37 | build: 38 | name: Build 39 | runs-on: ubuntu-latest 40 | steps: 41 | - name: Check out files from GitHub 42 | uses: actions/checkout@v4 43 | with: 44 | submodules: recursive 45 | - name: Setup Node 46 | uses: actions/setup-node@v4 47 | with: 48 | node-version-file: ".nvmrc" 49 | cache: yarn 50 | - name: Install dependencies 51 | run: script/bootstrap 52 | 53 | - name: Build 54 | run: yarn build 55 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | 7 | jobs: 8 | update_release_draft: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - id: version 12 | run: | 13 | version=$(date --utc '+%Y.%-m.%-d.%-H%M%S') 14 | echo "::set-output name=version::$version" 15 | 16 | - uses: release-drafter/release-drafter@v6 17 | with: 18 | tag: ${{ steps.version.outputs.version }} 19 | name: ${{ steps.version.outputs.version }} 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Project files to ignore 2 | node_modules 3 | package-lock.json 4 | src/localize/generated.ts 5 | 6 | yarn-error.log 7 | .yarn 8 | 9 | result.md 10 | 11 | # Byte-compiled / optimized / DLL files 12 | __pycache__/ 13 | *.py[cod] 14 | *$py.class 15 | 16 | # C extensions 17 | *.so 18 | 19 | # Distribution / packaging 20 | .Python 21 | env/ 22 | build/ 23 | knx_frontend/ 24 | develop-eggs/ 25 | dist/ 26 | downloads/ 27 | eggs/ 28 | .eggs/ 29 | lib/ 30 | lib64/ 31 | parts/ 32 | sdist/ 33 | var/ 34 | *.egg-info/ 35 | .installed.cfg 36 | *.egg 37 | 38 | # PyInstaller 39 | # Usually these files are written by a python script from a template 40 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 41 | *.manifest 42 | *.spec 43 | 44 | # Installer logs 45 | pip-log.txt 46 | pip-delete-this-directory.txt 47 | 48 | # Development environment 49 | .vscode 50 | .idea 51 | .DS_Store 52 | 53 | # Virtual Envs 54 | .venv 55 | venv -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "homeassistant-frontend"] 2 | path = homeassistant-frontend 3 | url = https://github.com/home-assistant/frontend.git 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | yarn run lint:eslint 2 | yarn run lint:prettier 3 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/iron -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": false, 4 | "printWidth": 100 5 | } 6 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | compressionLevel: mixed 2 | 3 | defaultSemverRangePrefix: "" 4 | 5 | enableGlobalCache: false 6 | 7 | nodeLinker: node-modules 8 | 9 | yarnPath: homeassistant-frontend/.yarn/releases/yarn-4.9.1.cjs 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 - (x)KNX Panel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft knx_frontend 2 | global-exclude *.py[cod] 3 | include VERSION 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := help 2 | 3 | help: ## Shows help message. 4 | @printf "\033[1m%s\033[36m %s\033[32m %s\033[0m \n\n" "Development environment for" "KNX Panel" "Frontend"; 5 | @awk 'BEGIN {FS = ":.*##";} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m make %-18s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST); 6 | @echo 7 | 8 | init: 9 | make bootstrap; 10 | 11 | develop: ## Start the frontend 12 | script/develop; 13 | 14 | bootstrap: ## Bootstrap the repository 15 | script/bootstrap; 16 | 17 | build: ## Build the repository 18 | script/build; 19 | 20 | update: ## Pull main from xknx/knx-frontend 21 | git pull upstream main; 22 | 23 | update-submodule: ## Udpate submodules 24 | rm -R homeassistant-frontend; 25 | git submodule update --init --recursive --remote; 26 | script/bootstrap 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # KNX UI 2 | 3 | This is the KNX panel for the KNX core integration in Home Assistant. It 4 | provides a user interface for interacting with the KNX integration. 5 | 6 | ## Features 7 | 8 | * Info: 9 | ![Info](./screenshots/info.png?raw=true) 10 | * Get an overview of your current KNX installation state (shows if connected 11 | to the Bus, which XKNX version is running and the currently assigned 12 | Individual address) 13 | * Upload ETS project file (which is used in the Group Monitor to provide 14 | destination names and DPT interpretation) and delete it again from Home 15 | Assistant. 16 | * Get key information about the parsed ETS project which has been uploaded 17 | * Group Monitor: Use the interactive bus monitor to view all incoming and 18 | outgoing telegrams on the bus. 19 | ![Group Monitor](./screenshots/bus_monitor.png?raw=true) 20 | * ETS Project: Displays the Group Addresses provided via ETS Project in a tree view 21 | 22 | ## Development 23 | 24 | If you check this repository out for the first time please run the following command to init the submodules: 25 | 26 | ```shell 27 | $ make bootstrap 28 | ... 29 | ``` 30 | 31 | ### Development build (watcher) 32 | 33 | ```shell 34 | $ make develop 35 | ... 36 | ``` 37 | 38 | ### Production build 39 | 40 | ```shell 41 | $ make build 42 | ... 43 | ``` 44 | 45 | ### Update the home assistant frontend 46 | 47 | Replace latest_tag with the current release tag. 48 | 49 | ```shell 50 | $ cd homeassistant-frontend 51 | $ git fetch 52 | ... 53 | $ git checkout latest_tag 54 | ... 55 | $ cd .. 56 | $ rm -f yarn.lock 57 | $ node ./script/merge_requirements.js 58 | ... 59 | $ script/bootstrap 60 | ... 61 | ``` 62 | 63 | ### Testing the panel 64 | 65 | First of all we recommend to follow the instructions for 66 | [preparing a home assistant development environment][hassos_dev_env]. 67 | 68 | You can test the panel by symlinking the build result directory `knx_frontend` 69 | into your Home Assistant configuration directory. 70 | 71 | Assuming: 72 | 73 | * The `knx-frontend` repository is located at `` path 74 | * The `home-assistant-core` repository is located at `` path (Remark: per default the Home Assistant configuration directory will be created within `/config`) 75 | 76 | ```shell 77 | $ ln -s /knx_frontend /config/deps/lib/python3.xx/site-packages/ 78 | $ hass -c config 79 | ... 80 | ``` 81 | or on a venv-install 82 | ```shell 83 | $ cd 84 | $ script/setup 85 | # Next step might be optional 86 | $ source venv/bin/activate 87 | $ export PYTHONPATH= 88 | $ hass 89 | ... 90 | ``` 91 | 92 | Now `hass` (Home Assistant Core) should run on your machine and the knx panel is 93 | accessible at http://localhost:8123/knx. 94 | 95 | [hassos_dev_env]: https://developers.home-assistant.io/docs/development_environment/ 96 | 97 | On Home Assistant OS you might use https://github.com/home-assistant/addons-development/tree/master/custom_deps 98 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | dev -------------------------------------------------------------------------------- /build-scripts/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "import/no-extraneous-dependencies": 0, 4 | "no-restricted-syntax": 0, 5 | "no-console": 0 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /build-scripts/README.md: -------------------------------------------------------------------------------- 1 | # Bundling Home Assistant Frontend 2 | 3 | The Home Assistant build pipeline contains various steps to prepare a build. 4 | 5 | - Generating icon files to be included 6 | - Generating translation files to be included 7 | - Converting TypeScript, CSS and JSON files to JavaScript 8 | - Bundling 9 | - Minifying the files 10 | - Generating the HTML entrypoint files 11 | - Generating the service worker 12 | - Compressing the files 13 | 14 | ## Converting files 15 | 16 | Currently in Home Assistant we use a bundler to convert TypeScript, CSS and JSON files to JavaScript files that the browser understands. 17 | 18 | We currently rely on Webpack. Both of these programs bundle the converted files in both production and development. 19 | 20 | For development, bundling is optional. We just want to get the right files in the browser. 21 | 22 | Responsibilities of the converter during development: 23 | 24 | - Convert TypeScript to JavaScript 25 | - Convert CSS to JavaScript that sets the content as the default export 26 | - Convert JSON to JavaScript that sets the content as the default export 27 | - Make sure import, dynamic import and web worker references work 28 | - Add extensions where missing 29 | - Resolve absolute package imports 30 | - Filter out specific imports/packages 31 | - Replace constants with values 32 | 33 | In production, the following responsibilities are added: 34 | 35 | - Minify HTML 36 | - Bundle multiple imports so that the browser can fetch less files 37 | - Generate a second version that is ES5 compatible 38 | 39 | Configuration for all these steps are specified in [bundle.js](bundle.js). 40 | -------------------------------------------------------------------------------- /build-scripts/babel-plugins/custom-polyfill-plugin.js: -------------------------------------------------------------------------------- 1 | import defineProvider from "@babel/helper-define-polyfill-provider"; 2 | import { join } from "node:path"; 3 | import paths from "../paths.cjs"; 4 | 5 | const POLYFILL_DIR = join(paths.root_dir, "homeassistant-frontend/src/resources/polyfills"); 6 | 7 | // List of polyfill keys with supported browser targets for the functionality 8 | const polyfillSupport = { 9 | // Note states and shadowRoot properties should be supported. 10 | "element-internals": { 11 | android: 90, 12 | chrome: 90, 13 | edge: 90, 14 | firefox: 126, 15 | ios: 17.4, 16 | opera: 76, 17 | opera_mobile: 64, 18 | safari: 17.4, 19 | samsung: 15.0, 20 | }, 21 | "element-getattributenames": { 22 | android: 61, 23 | chrome: 61, 24 | edge: 18, 25 | firefox: 45, 26 | ios: 10.3, 27 | opera: 48, 28 | opera_mobile: 45, 29 | safari: 10.1, 30 | samsung: 8.0, 31 | }, 32 | "element-toggleattribute": { 33 | android: 69, 34 | chrome: 69, 35 | edge: 18, 36 | firefox: 63, 37 | ios: 12.0, 38 | opera: 56, 39 | opera_mobile: 48, 40 | safari: 12.0, 41 | samsung: 10.0, 42 | }, 43 | // FormatJS polyfill detects fix for https://bugs.chromium.org/p/v8/issues/detail?id=10682, 44 | // so adjusted to several months after that was marked fixed 45 | "intl-getcanonicallocales": { 46 | android: 90, 47 | chrome: 90, 48 | edge: 90, 49 | firefox: 48, 50 | ios: 10.3, 51 | opera: 76, 52 | opera_mobile: 64, 53 | safari: 10.1, 54 | samsung: 15.0, 55 | }, 56 | "intl-locale": { 57 | android: 74, 58 | chrome: 74, 59 | edge: 79, 60 | firefox: 75, 61 | ios: 14.0, 62 | opera: 62, 63 | opera_mobile: 53, 64 | safari: 14.0, 65 | samsung: 11.0, 66 | }, 67 | "intl-other": { 68 | // Not specified (i.e. always try polyfill) since compatibility depends on supported locales 69 | }, 70 | "resize-observer": { 71 | android: 64, 72 | chrome: 64, 73 | edge: 79, 74 | firefox: 69, 75 | ios: 13.4, 76 | opera: 51, 77 | opera_mobile: 47, 78 | safari: 13.1, 79 | samsung: 9.0, 80 | }, 81 | }; 82 | 83 | // Map of global variables and/or instance and static properties to the 84 | // corresponding polyfill key and actual module to import 85 | const polyfillMap = { 86 | global: { 87 | ResizeObserver: { 88 | key: "resize-observer", 89 | module: join(POLYFILL_DIR, "resize-observer.ts"), 90 | }, 91 | }, 92 | instance: { 93 | attachInternals: { 94 | key: "element-internals", 95 | module: "element-internals-polyfill", 96 | }, 97 | ...Object.fromEntries( 98 | ["getAttributeNames", "toggleAttribute"].map((prop) => { 99 | const key = `element-${prop.toLowerCase()}`; 100 | return [prop, { key, module: join(POLYFILL_DIR, `${key}.ts`) }]; 101 | }), 102 | ), 103 | }, 104 | static: { 105 | Intl: { 106 | getCanonicalLocales: { 107 | key: "intl-getcanonicallocales", 108 | module: join(POLYFILL_DIR, "intl-polyfill.ts"), 109 | }, 110 | Locale: { 111 | key: "intl-locale", 112 | module: join(POLYFILL_DIR, "intl-polyfill.ts"), 113 | }, 114 | ...Object.fromEntries( 115 | [ 116 | "DateTimeFormat", 117 | "DurationFormat", 118 | "DisplayNames", 119 | "ListFormat", 120 | "NumberFormat", 121 | "PluralRules", 122 | "RelativeTimeFormat", 123 | ].map((obj) => [ 124 | obj, 125 | { key: "intl-other", module: join(POLYFILL_DIR, "intl-polyfill.ts") }, 126 | ]), 127 | ), 128 | }, 129 | }, 130 | }; 131 | 132 | // Create plugin using the same factory as for CoreJS 133 | export default defineProvider(({ createMetaResolver, debug, shouldInjectPolyfill }) => { 134 | const resolvePolyfill = createMetaResolver(polyfillMap); 135 | return { 136 | name: "custom-polyfill", 137 | polyfills: polyfillSupport, 138 | usageGlobal(meta, utils) { 139 | const polyfill = resolvePolyfill(meta); 140 | if (polyfill && shouldInjectPolyfill(polyfill.desc.key)) { 141 | debug(polyfill.desc.key); 142 | utils.injectGlobalImport(polyfill.desc.module); 143 | return true; 144 | } 145 | return false; 146 | }, 147 | }; 148 | }); 149 | -------------------------------------------------------------------------------- /build-scripts/babel-plugins/inline-constants-plugin.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const path = require("path"); 3 | 4 | // Currently only supports CommonJS modules, as require is synchronous. `import` would need babel running asynchronous. 5 | module.exports = function inlineConstants(babel, options, cwd) { 6 | const t = babel.types; 7 | 8 | if (!Array.isArray(options.modules)) { 9 | throw new TypeError( 10 | "babel-plugin-inline-constants: expected a `modules` array to be passed" 11 | ); 12 | } 13 | 14 | if (options.resolveExtensions && !Array.isArray(options.resolveExtensions)) { 15 | throw new TypeError( 16 | "babel-plugin-inline-constants: expected `resolveExtensions` to be an array" 17 | ); 18 | } 19 | 20 | const ignoreModuleNotFound = options.ignoreModuleNotFound; 21 | const resolveExtensions = options.resolveExtensions; 22 | 23 | const hasRelativeModules = options.modules.some( 24 | (module) => module.startsWith(".") || module.startsWith("/") 25 | ); 26 | 27 | const modules = Object.fromEntries( 28 | options.modules.map((module) => { 29 | const absolute = module.startsWith(".") 30 | ? require.resolve(module, { paths: [cwd] }) 31 | : module; 32 | // eslint-disable-next-line import/no-dynamic-require 33 | return [absolute, require(absolute)]; 34 | }) 35 | ); 36 | 37 | const toLiteral = (value) => { 38 | if (typeof value === "string") { 39 | return t.stringLiteral(value); 40 | } 41 | 42 | if (typeof value === "number") { 43 | return t.numericLiteral(value); 44 | } 45 | 46 | if (typeof value === "boolean") { 47 | return t.booleanLiteral(value); 48 | } 49 | 50 | if (value === null) { 51 | return t.nullLiteral(); 52 | } 53 | 54 | throw new Error( 55 | "babel-plugin-inline-constants: cannot handle non-literal `" + value + "`" 56 | ); 57 | }; 58 | 59 | const resolveAbsolute = (value, state, resolveExtensionIndex) => { 60 | if (!state.filename) { 61 | throw new TypeError( 62 | "babel-plugin-inline-constants: expected a `filename` to be set for files" 63 | ); 64 | } 65 | 66 | if (resolveExtensions && resolveExtensionIndex !== undefined) { 67 | value += resolveExtensions[resolveExtensionIndex]; 68 | } 69 | 70 | try { 71 | return require.resolve(value, { paths: [path.dirname(state.filename)] }); 72 | } catch (error) { 73 | if ( 74 | error.code === "MODULE_NOT_FOUND" && 75 | resolveExtensions && 76 | (resolveExtensionIndex === undefined || 77 | resolveExtensionIndex < resolveExtensions.length - 1) 78 | ) { 79 | const resolveExtensionIdx = (resolveExtensionIndex || -1) + 1; 80 | return resolveAbsolute(value, state, resolveExtensionIdx); 81 | } 82 | 83 | if (error.code === "MODULE_NOT_FOUND" && ignoreModuleNotFound) { 84 | return undefined; 85 | } 86 | throw error; 87 | } 88 | }; 89 | 90 | const importDeclaration = (p, state) => { 91 | if (p.node.type !== "ImportDeclaration") { 92 | return; 93 | } 94 | const absolute = 95 | hasRelativeModules && p.node.source.value.startsWith(".") 96 | ? resolveAbsolute(p.node.source.value, state) 97 | : p.node.source.value; 98 | 99 | if (!absolute || !(absolute in modules)) { 100 | return; 101 | } 102 | 103 | const module = modules[absolute]; 104 | 105 | for (const specifier of p.node.specifiers) { 106 | if ( 107 | specifier.type === "ImportDefaultSpecifier" && 108 | specifier.local && 109 | specifier.local.type === "Identifier" 110 | ) { 111 | if (!("default" in module)) { 112 | throw new Error( 113 | "babel-plugin-inline-constants: cannot access default export from `" + 114 | p.node.source.value + 115 | "`" 116 | ); 117 | } 118 | 119 | const variableValue = toLiteral(module.default); 120 | const variable = t.variableDeclarator( 121 | t.identifier(specifier.local.name), 122 | variableValue 123 | ); 124 | 125 | p.insertBefore({ 126 | type: "VariableDeclaration", 127 | kind: "const", 128 | declarations: [variable], 129 | }); 130 | } else if ( 131 | specifier.type === "ImportSpecifier" && 132 | specifier.imported && 133 | specifier.imported.type === "Identifier" && 134 | specifier.local && 135 | specifier.local.type === "Identifier" 136 | ) { 137 | if (!(specifier.imported.name in module)) { 138 | throw new Error( 139 | "babel-plugin-inline-constants: cannot access `" + 140 | specifier.imported.name + 141 | "` from `" + 142 | p.node.source.value + 143 | "`" 144 | ); 145 | } 146 | 147 | const variableValue = toLiteral(module[specifier.imported.name]); 148 | const variable = t.variableDeclarator( 149 | t.identifier(specifier.local.name), 150 | variableValue 151 | ); 152 | 153 | p.insertBefore({ 154 | type: "VariableDeclaration", 155 | kind: "const", 156 | declarations: [variable], 157 | }); 158 | } else { 159 | throw new Error("Cannot handle specifier `" + specifier.type + "`"); 160 | } 161 | } 162 | p.remove(); 163 | }; 164 | 165 | return { 166 | visitor: { 167 | ImportDeclaration: importDeclaration, 168 | }, 169 | }; 170 | }; 171 | -------------------------------------------------------------------------------- /build-scripts/bundle.cjs: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const env = require("./env.cjs"); 3 | const paths = require("./paths.cjs"); 4 | const { dependencies } = require("../package.json"); 5 | 6 | // Files from NPM Packages that should not be imported 7 | module.exports.ignorePackages = () => []; 8 | 9 | // Files from NPM packages that we should replace with empty file 10 | module.exports.emptyPackages = ({ isHassioBuild }) => 11 | [ 12 | require.resolve("@vaadin/vaadin-material-styles/typography.js"), 13 | require.resolve("@vaadin/vaadin-material-styles/font-icons.js"), 14 | // Icons in supervisor conflict with icons in HA so we don't load. 15 | isHassioBuild && 16 | require.resolve( 17 | path.resolve(paths.root_dir, "homeassistant-frontend/src/components/ha-icon.ts"), 18 | ), 19 | isHassioBuild && 20 | require.resolve( 21 | path.resolve(paths.root_dir, "homeassistant-frontend/src/components/ha-icon-picker.ts"), 22 | ), 23 | ].filter(Boolean); 24 | 25 | module.exports.definedVars = ({ isProdBuild, latestBuild, defineOverlay }) => ({ 26 | __DEV__: !isProdBuild, 27 | __BUILD__: JSON.stringify(latestBuild ? "latest" : "es5"), 28 | __VERSION__: JSON.stringify(env.version()), 29 | __DEMO__: false, 30 | __SUPERVISOR__: false, 31 | __BACKWARDS_COMPAT__: false, 32 | __STATIC_PATH__: "/static/", 33 | "process.env.NODE_ENV": JSON.stringify(isProdBuild ? "production" : "development"), 34 | ...defineOverlay, 35 | }); 36 | 37 | module.exports.terserOptions = ({ latestBuild, isTestBuild }) => ({ 38 | safari10: !latestBuild, 39 | ecma: latestBuild ? 2015 : 5, 40 | module: latestBuild, 41 | format: { comments: false }, 42 | sourceMap: !isTestBuild, 43 | }); 44 | 45 | /** @type {import('@rspack/core').SwcLoaderOptions} */ 46 | module.exports.swcOptions = () => ({ 47 | jsc: { 48 | loose: true, 49 | externalHelpers: true, 50 | target: "ES2021", 51 | parser: { 52 | syntax: "typescript", 53 | decorators: true, 54 | }, 55 | }, 56 | }); 57 | 58 | module.exports.babelOptions = ({ latestBuild }) => ({ 59 | babelrc: false, 60 | compact: false, 61 | assumptions: { 62 | privateFieldsAsProperties: true, 63 | setPublicClassFields: true, 64 | setSpreadProperties: true, 65 | }, 66 | browserslistEnv: latestBuild ? "modern" : "legacy", 67 | presets: [ 68 | [ 69 | "@babel/preset-env", 70 | { 71 | useBuiltIns: latestBuild ? false : "usage", 72 | corejs: latestBuild ? false : dependencies["core-js"], 73 | bugfixes: true, 74 | shippedProposals: true, 75 | }, 76 | ], 77 | ], 78 | plugins: [ 79 | [ 80 | path.resolve( 81 | paths.root_dir, 82 | "homeassistant-frontend/build-scripts/babel-plugins/inline-constants-plugin.cjs", 83 | ), 84 | { 85 | modules: ["@mdi/js"], 86 | ignoreModuleNotFound: true, 87 | }, 88 | ], 89 | [ 90 | path.resolve( 91 | paths.root_dir, 92 | "homeassistant-frontend/build-scripts/babel-plugins/custom-polyfill-plugin.js", 93 | ), 94 | { method: "usage-global" }, 95 | ], 96 | // Import helpers and regenerator from runtime package 97 | ["@babel/plugin-transform-runtime", { version: dependencies["@babel/runtime"] }], 98 | "@babel/plugin-transform-class-properties", 99 | "@babel/plugin-transform-private-methods", 100 | ].filter(Boolean), 101 | exclude: [ 102 | // \\ for Windows, / for Mac OS and Linux 103 | /node_modules[\\/]core-js/, 104 | /node_modules[\\/]webpack[\\/]buildin/, 105 | ], 106 | overrides: [ 107 | { 108 | // Use unambiguous for dependencies so that require() is correctly injected into CommonJS files 109 | // Exclusions are needed in some cases where ES modules have no static imports or exports, such as polyfills 110 | sourceType: "unambiguous", 111 | include: /\/node_modules\//, 112 | exclude: [ 113 | "element-internals-polyfill", 114 | "@shoelace-style", 115 | "@?lit(?:-labs|-element|-html)?", 116 | ].map((p) => new RegExp(`/node_modules/${p}/`)), 117 | }, 118 | ], 119 | }); 120 | 121 | const outputPath = (outputRoot, latestBuild) => 122 | path.resolve(outputRoot, latestBuild ? "frontend_latest" : "frontend_es5"); 123 | 124 | const publicPath = (latestBuild, root = "") => 125 | latestBuild ? `${root}/frontend_latest/` : `${root}/frontend_es5/`; 126 | 127 | module.exports.config = { 128 | knx({ isProdBuild, latestBuild }) { 129 | return { 130 | entry: { 131 | entrypoint: path.resolve(paths.knx_dir, "src/entrypoint.ts"), 132 | }, 133 | outputPath: outputPath(paths.knx_output_root, latestBuild), 134 | publicPath: publicPath(latestBuild, paths.knx_publicPath), 135 | isProdBuild, 136 | latestBuild, 137 | isHassioBuild: true, 138 | }; 139 | }, 140 | }; 141 | -------------------------------------------------------------------------------- /build-scripts/env.cjs: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | const paths = require("./paths.cjs"); 4 | 5 | const isTrue = (value) => value === "1" || value?.toLowerCase() === "true"; 6 | module.exports = { 7 | useRollup() { 8 | return isTrue(process.env.ROLLUP); 9 | }, 10 | useWDS() { 11 | return isTrue(process.env.WDS); 12 | }, 13 | isProdBuild() { 14 | return process.env.NODE_ENV === "production" || module.exports.isStatsBuild(); 15 | }, 16 | isStatsBuild() { 17 | return isTrue(process.env.STATS); 18 | }, 19 | isTest() { 20 | return isTrue(process.env.IS_TEST); 21 | }, 22 | isNetlify() { 23 | return isTrue(process.env.NETLIFY); 24 | }, 25 | version() { 26 | const version = fs.readFileSync(path.resolve(paths.root_dir, "VERSION"), "utf8"); 27 | if (!version) { 28 | throw Error("Version not found"); 29 | } 30 | return version.trim(); 31 | }, 32 | isDevContainer() { 33 | return isTrue(process.env.DEV_CONTAINER); 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /build-scripts/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import tseslint from "typescript-eslint"; 4 | import rootConfig from "../eslint.config.mjs"; 5 | 6 | export default tseslint.config(...rootConfig, { 7 | rules: { 8 | "no-console": "off", 9 | "import/no-extraneous-dependencies": "off", 10 | "import/extensions": "off", 11 | "import/no-dynamic-require": "off", 12 | "global-require": "off", 13 | "@typescript-eslint/no-require-imports": "off", 14 | "prefer-arrow-callback": "off", 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /build-scripts/gulp/clean.js: -------------------------------------------------------------------------------- 1 | import { deleteSync } from "del"; 2 | import gulp from "gulp"; 3 | import paths from "../paths.cjs"; 4 | 5 | gulp.task("clean-knx", async () => 6 | deleteSync([paths.knx_output_root, paths.build_dir]) 7 | ); 8 | -------------------------------------------------------------------------------- /build-scripts/gulp/compress.js: -------------------------------------------------------------------------------- 1 | // Tasks to compress 2 | 3 | import { constants } from "node:zlib"; 4 | import gulp from "gulp"; 5 | import brotli from "gulp-brotli"; 6 | import paths from "../paths.cjs"; 7 | 8 | const filesGlob = "*.{js,json,css,svg,xml}"; 9 | const brotliOptions = { 10 | skipLarger: true, 11 | params: { 12 | [constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY, 13 | }, 14 | }; 15 | 16 | const compressModern = (rootDir, modernDir) => 17 | gulp 18 | .src([`${modernDir}/**/${filesGlob}`, `${rootDir}/sw-modern.js`], { 19 | base: rootDir, 20 | allowEmpty: true, 21 | }) 22 | .pipe(brotli(brotliOptions)) 23 | .pipe(gulp.dest(rootDir)); 24 | 25 | const compressOther = (rootDir, modernDir) => 26 | gulp 27 | .src( 28 | [ 29 | `${rootDir}/**/${filesGlob}`, 30 | `!${modernDir}/**/${filesGlob}`, 31 | `!${rootDir}/{sw-modern,service_worker}.js`, 32 | `${rootDir}/{authorize,onboarding}.html`, 33 | ], 34 | { base: rootDir, allowEmpty: true }, 35 | ) 36 | .pipe(brotli(brotliOptions)) 37 | .pipe(gulp.dest(rootDir)); 38 | 39 | const compressKnxModern = () => compressModern(paths.knx_output_root, paths.knx_output_latest); 40 | const compressKnxOther = () => compressOther(paths.knx_output_root, paths.knx_output_latest); 41 | 42 | gulp.task("compress-knx", gulp.parallel(compressKnxModern, compressKnxOther)); 43 | -------------------------------------------------------------------------------- /build-scripts/gulp/entry-html.js: -------------------------------------------------------------------------------- 1 | // Tasks to generate entry HTML 2 | 3 | import fs from "fs-extra"; 4 | import gulp from "gulp"; 5 | import path from "path"; 6 | import paths from "../paths.cjs"; 7 | 8 | gulp.task("gen-index-knx-dev", async () => { 9 | const latestEntrypoint = `${paths.knx_publicPath}/frontend_latest/entrypoint.dev.js`; 10 | const es5Entrypoint = `${paths.knx_publicPath}/frontend_es5/entrypoint.dev.js`; 11 | const fileHash = getFileHash("entrypoint.dev.js"); 12 | 13 | writeKNXEntrypoint(fileHash, latestEntrypoint, es5Entrypoint); 14 | writePyModules(fileHash); 15 | }); 16 | 17 | gulp.task("gen-index-knx-prod", async () => { 18 | const latestManifest = fs.readJsonSync(path.resolve(paths.knx_output_latest, "manifest.json")); 19 | const es5Manifest = fs.readJsonSync(path.resolve(paths.knx_output_es5, "manifest.json")); 20 | const fileHash = getFileHash(latestManifest["entrypoint.js"]); 21 | 22 | writeKNXEntrypoint(fileHash, latestManifest["entrypoint.js"], es5Manifest["entrypoint.js"]); 23 | writePyModules(fileHash); 24 | }); 25 | 26 | function getFileHash(entrypointFileName) { 27 | // Filenames have .dev. or .. from ../rspack.cjs createRspackConfig().output.filename 28 | return entrypointFileName.split(".").slice(-2)[0]; 29 | } 30 | 31 | function writeKNXEntrypoint(fileHash, latestEntrypoint, es5Entrypoint) { 32 | fs.mkdirSync(paths.knx_output_root, { recursive: true }); 33 | // Safari 12 and below does not have a compliant ES2015 implementation of template literals, so we ship ES5 34 | fs.writeFileSync( 35 | path.resolve(paths.knx_output_root, `entrypoint.${fileHash}.js`), 36 | ` 37 | function loadES5() { 38 | var el = document.createElement('script'); 39 | el.src = '${es5Entrypoint}'; 40 | document.body.appendChild(el); 41 | } 42 | if (/.*Version\\/(?:11|12)(?:\\.\\d+)*.*Safari\\//.test(navigator.userAgent)) { 43 | loadES5(); 44 | } else { 45 | try { 46 | new Function("import('${latestEntrypoint}')")(); 47 | } catch (err) { 48 | loadES5(); 49 | } 50 | } 51 | `, 52 | { encoding: "utf-8" }, 53 | ); 54 | } 55 | 56 | function writePyModules(fileHash) { 57 | fs.writeFileSync( 58 | path.resolve(paths.knx_output_root, "constants.py"), 59 | `"""Constants for the KNX Panel. This file is generated by gulp.""" 60 | 61 | FILE_HASH = "${fileHash}" 62 | `, 63 | { encoding: "utf-8" }, 64 | ); 65 | fs.copyFileSync( 66 | path.resolve(paths.src_dir, `__init__.py`), 67 | path.resolve(paths.knx_output_root, `__init__.py`), 68 | ); 69 | fs.writeFileSync(path.resolve(paths.knx_output_root, "py.typed"), ""); 70 | } 71 | -------------------------------------------------------------------------------- /build-scripts/gulp/fetch-nightly-translations.js: -------------------------------------------------------------------------------- 1 | // Task to download the latest Lokalise translations from the nightly workflow artifacts 2 | 3 | import { createOAuthDeviceAuth } from "@octokit/auth-oauth-device"; 4 | import { retry } from "@octokit/plugin-retry"; 5 | import { Octokit } from "@octokit/rest"; 6 | import { deleteAsync } from "del"; 7 | import { mkdir, readFile, writeFile } from "fs/promises"; 8 | import gulp from "gulp"; 9 | import jszip from "jszip"; 10 | import path from "path"; 11 | import process from "process"; 12 | import { extract } from "tar"; 13 | 14 | const MAX_AGE = 24; // hours 15 | const OWNER = "home-assistant"; 16 | const REPO = "frontend"; 17 | const WORKFLOW_NAME = "nightly.yaml"; 18 | const ARTIFACT_NAME = "translations"; 19 | const CLIENT_ID = "Iv1.3914e28cb27834d1"; 20 | const EXTRACT_DIR = "translations"; 21 | const TOKEN_FILE = path.posix.join(EXTRACT_DIR, "token.json"); 22 | const ARTIFACT_FILE = path.posix.join(EXTRACT_DIR, "artifact.json"); 23 | 24 | let allowTokenSetup = false; 25 | gulp.task("allow-setup-fetch-nightly-translations", (done) => { 26 | allowTokenSetup = true; 27 | done(); 28 | }); 29 | 30 | gulp.task("fetch-nightly-translations", async function () { 31 | // Skip all when environment flag is set (assumes translations are already in place) 32 | if (process.env?.SKIP_FETCH_NIGHTLY_TRANSLATIONS) { 33 | console.log("Skipping fetch due to environment signal"); 34 | return; 35 | } 36 | 37 | // Read current translations artifact info if it exists, 38 | // and stop if they are not old enough 39 | let currentArtifact; 40 | try { 41 | currentArtifact = JSON.parse(await readFile(ARTIFACT_FILE, "utf-8")); 42 | const currentAge = 43 | (Date.now() - Date.parse(currentArtifact.created_at)) / 3600000; 44 | if (currentAge < MAX_AGE) { 45 | console.log( 46 | "Keeping current translations (only %s hours old)", 47 | currentAge.toFixed(1) 48 | ); 49 | return; 50 | } 51 | } catch { 52 | currentArtifact = null; 53 | } 54 | 55 | // To store file writing promises 56 | const createExtractDir = mkdir(EXTRACT_DIR, { recursive: true }); 57 | const writings = []; 58 | 59 | // Authenticate to GitHub using GitHub action token if it exists, 60 | // otherwise look for a saved user token or generate a new one if none 61 | let tokenAuth; 62 | if (process.env.GITHUB_TOKEN) { 63 | tokenAuth = { token: process.env.GITHUB_TOKEN }; 64 | } else { 65 | try { 66 | tokenAuth = JSON.parse(await readFile(TOKEN_FILE, "utf-8")); 67 | } catch { 68 | if (!allowTokenSetup) { 69 | console.log("No token found so build will continue with English only"); 70 | return; 71 | } 72 | const auth = createOAuthDeviceAuth({ 73 | clientType: "github-app", 74 | clientId: CLIENT_ID, 75 | onVerification: (verification) => { 76 | console.log( 77 | "Task needs to authenticate to GitHub to fetch the translations from nightly workflow\n" + 78 | "Please go to %s to authorize this task\n" + 79 | "\nEnter user code: %s\n\n" + 80 | "This code will expire in %s minutes\n" + 81 | "Task will automatically continue after authorization and token will be saved for future use", 82 | verification.verification_uri, 83 | verification.user_code, 84 | (verification.expires_in / 60).toFixed(0) 85 | ); 86 | }, 87 | }); 88 | tokenAuth = await auth({ type: "oauth" }); 89 | writings.push( 90 | createExtractDir.then( 91 | writeFile(TOKEN_FILE, JSON.stringify(tokenAuth, null, 2)) 92 | ) 93 | ); 94 | } 95 | } 96 | 97 | // Authenticate with token and request workflow runs from GitHub 98 | console.log("Fetching new translations..."); 99 | const octokit = new (Octokit.plugin(retry))({ 100 | userAgent: "Fetch Nightly Translations", 101 | auth: tokenAuth.token, 102 | }); 103 | 104 | const workflowRunsResponse = await octokit.rest.actions.listWorkflowRuns({ 105 | owner: OWNER, 106 | repo: REPO, 107 | workflow_id: WORKFLOW_NAME, 108 | status: "success", 109 | event: "schedule", 110 | per_page: 1, 111 | exclude_pull_requests: true, 112 | }); 113 | if (workflowRunsResponse.data.total_count === 0) { 114 | throw Error("No successful nightly workflow runs found"); 115 | } 116 | const latestNightlyRun = workflowRunsResponse.data.workflow_runs[0]; 117 | 118 | // Stop if current is already the latest, otherwise Find the translations artifact 119 | if (currentArtifact?.workflow_run.id === latestNightlyRun.id) { 120 | console.log("Stopping because current translations are still the latest"); 121 | return; 122 | } 123 | const latestArtifact = ( 124 | await octokit.actions.listWorkflowRunArtifacts({ 125 | owner: OWNER, 126 | repo: REPO, 127 | run_id: latestNightlyRun.id, 128 | }) 129 | ).data.artifacts.find((artifact) => artifact.name === ARTIFACT_NAME); 130 | if (!latestArtifact) { 131 | throw Error("Latest nightly workflow run has no translations artifact"); 132 | } 133 | writings.push( 134 | createExtractDir.then( 135 | writeFile(ARTIFACT_FILE, JSON.stringify(latestArtifact, null, 2)) 136 | ) 137 | ); 138 | 139 | // Remove the current translations 140 | const deleteCurrent = Promise.all(writings).then( 141 | deleteAsync([`${EXTRACT_DIR}/*`, `!${ARTIFACT_FILE}`, `!${TOKEN_FILE}`]) 142 | ); 143 | 144 | // Get the download URL and follow the redirect to download (stored as ArrayBuffer) 145 | const downloadResponse = await octokit.actions.downloadArtifact({ 146 | owner: OWNER, 147 | repo: REPO, 148 | artifact_id: latestArtifact.id, 149 | archive_format: "zip", 150 | }); 151 | if (downloadResponse.status !== 200) { 152 | throw Error("Failure downloading translations artifact"); 153 | } 154 | 155 | // Artifact is a tarball, but GitHub adds it to a zip file 156 | console.log("Unpacking downloaded translations..."); 157 | const zip = await jszip.loadAsync(downloadResponse.data); 158 | await deleteCurrent; 159 | const extractStream = zip.file(/.*/)[0].nodeStream().pipe(extract()); 160 | await new Promise((resolve, reject) => { 161 | extractStream.on("close", resolve).on("error", reject); 162 | }); 163 | }); 164 | 165 | gulp.task( 166 | "setup-and-fetch-nightly-translations", 167 | gulp.series( 168 | "allow-setup-fetch-nightly-translations", 169 | "fetch-nightly-translations" 170 | ) 171 | ); 172 | -------------------------------------------------------------------------------- /build-scripts/gulp/gen-icons-json.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import gulp from "gulp"; 3 | import hash from "object-hash"; 4 | import path from "path"; 5 | import paths from "../paths.cjs"; 6 | 7 | const ICON_PACKAGE_PATH = path.resolve("node_modules/@mdi/svg/"); 8 | const META_PATH = path.resolve(ICON_PACKAGE_PATH, "meta.json"); 9 | const PACKAGE_PATH = path.resolve(ICON_PACKAGE_PATH, "package.json"); 10 | const ICON_PATH = path.resolve(ICON_PACKAGE_PATH, "svg"); 11 | const OUTPUT_DIR = path.resolve(paths.upstream_build_dir, "mdi"); 12 | const REMOVED_ICONS_PATH = new URL("../removedIcons.json", import.meta.url); 13 | 14 | const encoding = "utf8"; 15 | 16 | const getMeta = () => { 17 | const file = fs.readFileSync(META_PATH, { encoding }); 18 | const meta = JSON.parse(file); 19 | return meta.map((icon) => { 20 | const svg = fs.readFileSync(`${ICON_PATH}/${icon.name}.svg`, { 21 | encoding, 22 | }); 23 | return { 24 | path: svg.match(/ d="([^"]+)"/)[1], 25 | name: icon.name, 26 | tags: icon.tags, 27 | aliases: icon.aliases, 28 | }; 29 | }); 30 | }; 31 | 32 | const addRemovedMeta = (meta) => { 33 | const file = fs.readFileSync(REMOVED_ICONS_PATH, { encoding }); 34 | const removed = JSON.parse(file); 35 | const removedMeta = removed.map((removeIcon) => ({ 36 | path: removeIcon.path, 37 | name: removeIcon.name, 38 | tags: [], 39 | aliases: [], 40 | })); 41 | const combinedMeta = [...meta, ...removedMeta]; 42 | return combinedMeta.sort((a, b) => a.name.localeCompare(b.name)); 43 | }; 44 | 45 | const homeAutomationTag = "Home Automation"; 46 | 47 | const orderMeta = (meta) => { 48 | const homeAutomationMeta = meta.filter((icon) => 49 | icon.tags.includes(homeAutomationTag) 50 | ); 51 | const otherMeta = meta.filter( 52 | (icon) => !icon.tags.includes(homeAutomationTag) 53 | ); 54 | return [...homeAutomationMeta, ...otherMeta]; 55 | }; 56 | 57 | const splitBySize = (meta) => { 58 | const chunks = []; 59 | const CHUNK_SIZE = 50000; 60 | 61 | let curSize = 0; 62 | let startKey; 63 | let icons = []; 64 | 65 | Object.values(meta).forEach((icon) => { 66 | if (startKey === undefined) { 67 | startKey = icon.name; 68 | } 69 | curSize += icon.path.length; 70 | icons.push(icon); 71 | if (curSize > CHUNK_SIZE) { 72 | chunks.push({ 73 | startKey, 74 | endKey: icon.name, 75 | icons, 76 | }); 77 | curSize = 0; 78 | startKey = undefined; 79 | icons = []; 80 | } 81 | }); 82 | 83 | chunks.push({ 84 | startKey, 85 | icons, 86 | }); 87 | 88 | return chunks; 89 | }; 90 | 91 | const findDifferentiator = (curString, prevString) => { 92 | for (let i = 0; i < curString.length; i++) { 93 | if (curString[i] !== prevString[i]) { 94 | return curString.substring(0, i + 1); 95 | } 96 | } 97 | throw new Error("Cannot find differentiator", curString, prevString); 98 | }; 99 | 100 | gulp.task("gen-icons-json", (done) => { 101 | const meta = getMeta(); 102 | 103 | const metaAndRemoved = addRemovedMeta(meta); 104 | const split = splitBySize(metaAndRemoved); 105 | 106 | if (!fs.existsSync(OUTPUT_DIR)) { 107 | fs.mkdirSync(OUTPUT_DIR, { recursive: true }); 108 | } 109 | const parts = []; 110 | 111 | let lastEnd; 112 | split.forEach((chunk) => { 113 | let startKey; 114 | if (lastEnd === undefined) { 115 | chunk.startKey = undefined; 116 | startKey = undefined; 117 | } else { 118 | startKey = findDifferentiator(chunk.startKey, lastEnd); 119 | } 120 | lastEnd = chunk.endKey; 121 | 122 | const output = {}; 123 | chunk.icons.forEach((icon) => { 124 | output[icon.name] = icon.path; 125 | }); 126 | const filename = hash(output); 127 | parts.push({ start: startKey, file: filename }); 128 | fs.writeFileSync( 129 | path.resolve(OUTPUT_DIR, `${filename}.json`), 130 | JSON.stringify(output) 131 | ); 132 | }); 133 | 134 | const file = fs.readFileSync(PACKAGE_PATH, { encoding }); 135 | const packageMeta = JSON.parse(file); 136 | 137 | fs.writeFileSync( 138 | path.resolve(OUTPUT_DIR, "iconMetadata.json"), 139 | JSON.stringify({ version: packageMeta.version, parts }) 140 | ); 141 | 142 | fs.writeFileSync( 143 | path.resolve(OUTPUT_DIR, "iconList.json"), 144 | JSON.stringify( 145 | orderMeta(meta).map((icon) => ({ 146 | name: icon.name, 147 | keywords: [ 148 | ...icon.tags.map((t) => t.toLowerCase().replace(/\s\/\s/g, " ")), 149 | ...icon.aliases, 150 | ], 151 | })) 152 | ) 153 | ); 154 | 155 | done(); 156 | }); 157 | 158 | gulp.task("gen-dummy-icons-json", (done) => { 159 | if (!fs.existsSync(OUTPUT_DIR)) { 160 | fs.mkdirSync(OUTPUT_DIR, { recursive: true }); 161 | } 162 | 163 | fs.writeFileSync(path.resolve(OUTPUT_DIR, "iconList.json"), "[]"); 164 | done(); 165 | }); 166 | -------------------------------------------------------------------------------- /build-scripts/gulp/index.mjs: -------------------------------------------------------------------------------- 1 | import "./clean.js"; 2 | import "./compress.js"; 3 | import "./entry-html.js"; 4 | import "./fetch-nightly-translations.js"; 5 | import "./gen-icons-json.js"; 6 | import "./knx.js"; 7 | import "./locale-data.js"; 8 | import "./rspack.js"; 9 | import "./translations.js"; 10 | -------------------------------------------------------------------------------- /build-scripts/gulp/knx.js: -------------------------------------------------------------------------------- 1 | import gulp from "gulp"; 2 | import env from "../env.cjs"; 3 | 4 | import "./clean.js"; 5 | import "./compress.js"; 6 | import "./entry-html.js"; 7 | import "./gen-icons-json.js"; 8 | import "./rspack.js"; 9 | import "./translations.js"; 10 | import "./locale-data.js"; 11 | 12 | gulp.task( 13 | "develop-knx", 14 | gulp.series( 15 | async () => { 16 | process.env.NODE_ENV = "development"; 17 | }, 18 | "clean-knx", 19 | "gen-icons-json", 20 | "build-translations", 21 | "build-locale-data", 22 | "gen-index-knx-dev", 23 | "rspack-watch-knx", 24 | ), 25 | ); 26 | 27 | gulp.task( 28 | "build-knx", 29 | gulp.series( 30 | async () => { 31 | process.env.NODE_ENV = "production"; 32 | }, 33 | "clean-knx", 34 | "ensure-knx-build-dir", 35 | "gen-icons-json", 36 | "build-translations", 37 | "build-locale-data", 38 | "rspack-prod-knx", 39 | "gen-index-knx-prod", 40 | ...// Don't compress running tests 41 | (env.isTest() ? [] : ["compress-knx"]), 42 | ), 43 | ); 44 | -------------------------------------------------------------------------------- /build-scripts/gulp/locale-data.js: -------------------------------------------------------------------------------- 1 | import { deleteSync } from "del"; 2 | import { mkdir, readFile, writeFile } from "fs/promises"; 3 | import gulp from "gulp"; 4 | import { join, resolve } from "node:path"; 5 | import paths from "../paths.cjs"; 6 | 7 | const formatjsDir = join(paths.root_dir, "node_modules", "@formatjs"); 8 | const outDir = join(paths.upstream_build_dir, "locale-data"); 9 | 10 | const INTL_POLYFILLS = { 11 | "intl-datetimeformat": "DateTimeFormat", 12 | "intl-displaynames": "DisplayNames", 13 | "intl-listformat": "ListFormat", 14 | "intl-numberformat": "NumberFormat", 15 | "intl-relativetimeformat": "RelativeTimeFormat", 16 | }; 17 | 18 | const convertToJSON = async ( 19 | pkg, 20 | lang, 21 | subDir = "locale-data", 22 | addFunc = "__addLocaleData", 23 | skipMissing = true, 24 | ) => { 25 | let localeData; 26 | try { 27 | // use "pt" for "pt-BR", because "pt-BR" is unsupported by @formatjs 28 | const language = lang === "pt-BR" ? "pt" : lang; 29 | 30 | localeData = await readFile(join(formatjsDir, pkg, subDir, `${language}.js`), "utf-8"); 31 | } catch (e) { 32 | // Ignore if language is missing (i.e. not supported by @formatjs) 33 | if (e.code === "ENOENT" && skipMissing) { 34 | console.warn(`Skipped missing data for language ${lang} from ${pkg}`); 35 | return; 36 | } 37 | throw e; 38 | } 39 | // Convert to JSON 40 | const obj = INTL_POLYFILLS[pkg]; 41 | const dataRegex = new RegExp(`Intl\\.${obj}\\.${addFunc}\\((?.*)\\)`, "s"); 42 | localeData = localeData.match(dataRegex)?.groups?.data; 43 | if (!localeData) { 44 | throw Error(`Failed to extract data for language ${lang} from ${pkg}`); 45 | } 46 | // Parse to validate JSON, then stringify to minify 47 | localeData = JSON.stringify(JSON.parse(localeData)); 48 | await writeFile(join(outDir, `${pkg}/${lang}.json`), localeData); 49 | }; 50 | 51 | gulp.task("clean-locale-data", async () => deleteSync([outDir])); 52 | 53 | gulp.task("create-locale-data", async () => { 54 | const translationMeta = JSON.parse( 55 | await readFile(resolve(paths.translations_src, "translationMetadata.json"), "utf-8"), 56 | ); 57 | const conversions = []; 58 | for (const pkg of Object.keys(INTL_POLYFILLS)) { 59 | // eslint-disable-next-line no-await-in-loop 60 | await mkdir(join(outDir, pkg), { recursive: true }); 61 | for (const lang of Object.keys(translationMeta)) { 62 | conversions.push(convertToJSON(pkg, lang)); 63 | } 64 | } 65 | conversions.push(convertToJSON("intl-datetimeformat", "add-all-tz", ".", "__addTZData", false)); 66 | await Promise.all(conversions); 67 | }); 68 | 69 | gulp.task("build-locale-data", gulp.series("clean-locale-data", "create-locale-data")); 70 | -------------------------------------------------------------------------------- /build-scripts/gulp/rspack.js: -------------------------------------------------------------------------------- 1 | // Tasks to run rspack. 2 | 3 | import log from "fancy-log"; 4 | import fs from "fs"; 5 | import gulp from "gulp"; 6 | import rspack from "@rspack/core"; 7 | import { RspackDevServer } from "@rspack/dev-server"; 8 | import paths from "../paths.cjs"; 9 | import { createKNXConfig } from "../rspack.cjs"; 10 | 11 | const bothBuilds = (createConfigFunc, params) => [ 12 | createConfigFunc({ ...params, latestBuild: true }), 13 | createConfigFunc({ ...params, latestBuild: false }), 14 | ]; 15 | 16 | const isWsl = 17 | fs.existsSync("/proc/version") && 18 | fs.readFileSync("/proc/version", "utf-8").toLocaleLowerCase().includes("microsoft"); 19 | 20 | gulp.task("ensure-knx-build-dir", (done) => { 21 | if (!fs.existsSync(paths.knx_output_root)) { 22 | fs.mkdirSync(paths.knx_output_root, { recursive: true }); 23 | } 24 | if (!fs.existsSync(paths.app_output_root)) { 25 | fs.mkdirSync(paths.app_output_root, { recursive: true }); 26 | } 27 | done(); 28 | }); 29 | 30 | const doneHandler = (done) => (err, stats) => { 31 | if (err) { 32 | log.error(err.stack || err); 33 | if (err.details) { 34 | log.error(err.details); 35 | } 36 | return; 37 | } 38 | 39 | if (stats.hasErrors() || stats.hasWarnings()) { 40 | // eslint-disable-next-line no-console 41 | console.log(stats.toString("minimal")); 42 | } 43 | 44 | log(`Build done @ ${new Date().toLocaleTimeString()}`); 45 | 46 | if (done) { 47 | done(); 48 | } 49 | }; 50 | 51 | const prodBuild = (conf) => 52 | new Promise((resolve) => { 53 | rspack( 54 | conf, 55 | // Resolve promise when done. Because we pass a callback, rspack closes itself 56 | doneHandler(resolve), 57 | ); 58 | }); 59 | 60 | gulp.task("rspack-watch-knx", () => { 61 | // This command will run forever because we don't close compiler 62 | rspack( 63 | createKNXConfig({ 64 | isProdBuild: false, 65 | latestBuild: true, 66 | }), 67 | ).watch({ ignored: /build/, poll: isWsl }, doneHandler()); 68 | }); 69 | 70 | gulp.task("rspack-prod-knx", () => 71 | prodBuild( 72 | bothBuilds(createKNXConfig, { 73 | isProdBuild: true, 74 | }), 75 | ), 76 | ); 77 | -------------------------------------------------------------------------------- /build-scripts/paths.cjs: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = { 4 | root_dir: path.resolve(__dirname, ".."), 5 | 6 | src_dir: path.resolve(__dirname, "../src"), 7 | 8 | build_dir: path.resolve(__dirname, "../knx_frontend"), 9 | upstream_build_dir: path.resolve(__dirname, "../homeassistant-frontend/build"), 10 | app_output_root: path.resolve(__dirname, "../knx_frontend"), 11 | app_output_static: path.resolve(__dirname, "../knx_frontend/static"), 12 | app_output_latest: path.resolve(__dirname, "../knx_frontend/frontend_latest"), 13 | app_output_es5: path.resolve(__dirname, "../knx_frontend/frontend_es5"), 14 | 15 | knx_dir: path.resolve(__dirname, ".."), 16 | knx_output_root: path.resolve(__dirname, "../knx_frontend"), 17 | knx_output_static: path.resolve(__dirname, "../knx_frontend/static"), 18 | knx_output_latest: path.resolve(__dirname, "../knx_frontend/frontend_latest"), 19 | knx_output_es5: path.resolve(__dirname, "../knx_frontend/frontend_es5"), 20 | knx_publicPath: "/knx_static", 21 | 22 | translations_src: path.resolve(__dirname, "../homeassistant-frontend/src/translations"), 23 | }; 24 | -------------------------------------------------------------------------------- /build-scripts/removedIcons.json: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /build-scripts/rspack.cjs: -------------------------------------------------------------------------------- 1 | const { existsSync } = require("fs"); 2 | const path = require("path"); 3 | const rspack = require("@rspack/core"); 4 | // eslint-disable-next-line @typescript-eslint/naming-convention 5 | const { RsdoctorRspackPlugin } = require("@rsdoctor/rspack-plugin"); 6 | // eslint-disable-next-line @typescript-eslint/naming-convention 7 | const { StatsWriterPlugin } = require("webpack-stats-plugin"); 8 | const filterStats = require("@bundle-stats/plugin-webpack-filter"); 9 | // eslint-disable-next-line @typescript-eslint/naming-convention 10 | const TerserPlugin = require("terser-webpack-plugin"); 11 | const { WebpackManifestPlugin } = require("rspack-manifest-plugin"); 12 | const log = require("fancy-log"); 13 | // eslint-disable-next-line @typescript-eslint/naming-convention 14 | const WebpackBar = require("webpackbar/rspack"); 15 | const paths = require("./paths.cjs"); 16 | const bundle = require("./bundle.cjs"); 17 | 18 | class LogStartCompilePlugin { 19 | ignoredFirst = false; 20 | 21 | apply(compiler) { 22 | compiler.hooks.beforeCompile.tap("LogStartCompilePlugin", () => { 23 | if (!this.ignoredFirst) { 24 | this.ignoredFirst = true; 25 | return; 26 | } 27 | log("Changes detected. Starting compilation"); 28 | }); 29 | } 30 | } 31 | 32 | const createRspackConfig = ({ 33 | entry, 34 | outputPath, 35 | publicPath, 36 | defineOverlay, 37 | isProdBuild, 38 | latestBuild, 39 | isStatsBuild, 40 | isHassioBuild, 41 | dontHash, 42 | }) => { 43 | if (!dontHash) { 44 | dontHash = new Set(); 45 | } 46 | const ignorePackages = bundle.ignorePackages({ latestBuild }); 47 | return { 48 | mode: isProdBuild ? "production" : "development", 49 | target: `browserslist:${latestBuild ? "modern" : "legacy"}`, 50 | devtool: isProdBuild ? "cheap-module-source-map" : "eval-cheap-module-source-map", 51 | entry, 52 | node: false, 53 | module: { 54 | rules: [ 55 | { 56 | test: /\.m?js$|\.ts$/, 57 | exclude: /node_modules[\\/]core-js/, 58 | use: (info) => [ 59 | { 60 | loader: "babel-loader", 61 | options: { 62 | ...bundle.babelOptions({ latestBuild, sw: info.issuerLayer === "sw" }), 63 | cacheDirectory: !isProdBuild, 64 | cacheCompression: false, 65 | }, 66 | }, 67 | { 68 | loader: "builtin:swc-loader", 69 | options: bundle.swcOptions(), 70 | }, 71 | ], 72 | resolve: { 73 | fullySpecified: false, 74 | }, 75 | }, 76 | { 77 | test: /\.css$/, 78 | type: "asset/source", 79 | }, 80 | ], 81 | }, 82 | optimization: { 83 | minimizer: [ 84 | new TerserPlugin({ 85 | parallel: true, 86 | extractComments: true, 87 | terserOptions: bundle.terserOptions(latestBuild), 88 | }), 89 | ], 90 | moduleIds: isProdBuild && !isStatsBuild ? "deterministic" : "named", 91 | chunkIds: isProdBuild && !isStatsBuild ? "deterministic" : "named", 92 | splitChunks: { 93 | // Disable splitting for web workers with ESM output 94 | // Imports of external chunks are broken 95 | chunks: latestBuild 96 | ? (chunk) => !chunk.canBeInitial() && !/^.+-worker$/.test(chunk.name) 97 | : undefined, 98 | }, 99 | }, 100 | plugins: [ 101 | new WebpackBar({ fancy: !isProdBuild }), 102 | new WebpackManifestPlugin({ 103 | // Only include the JS of entrypoints 104 | filter: (file) => file.isInitial && !file.name.endsWith(".map"), 105 | }), 106 | new rspack.DefinePlugin(bundle.definedVars({ isProdBuild, latestBuild, defineOverlay })), 107 | new rspack.IgnorePlugin({ 108 | checkResource(resource, context) { 109 | // Only use ignore to intercept imports that we don't control 110 | // inside node_module dependencies. 111 | if ( 112 | !context.includes("/node_modules/") || 113 | // calling define.amd will call require("!!webpack amd options") 114 | resource.startsWith("!!webpack") || 115 | // loaded by webpack dev server but doesn't exist. 116 | resource === "webpack/hot" || 117 | resource.startsWith("@swc/helpers") 118 | ) { 119 | return false; 120 | } 121 | let fullPath; 122 | try { 123 | fullPath = resource.startsWith(".") 124 | ? path.resolve(context, resource) 125 | : require.resolve(resource); 126 | } catch (err) { 127 | console.error("Error in Home Assistant ignore plugin", resource, context); 128 | throw err; 129 | } 130 | 131 | return ignorePackages.some((toIgnorePath) => fullPath.startsWith(toIgnorePath)); 132 | }, 133 | }), 134 | new rspack.NormalModuleReplacementPlugin( 135 | new RegExp(bundle.emptyPackages({ isHassioBuild }).join("|")), 136 | path.resolve(paths.root_dir, "homeassistant-frontend/src/util/empty.js"), 137 | ), 138 | !isProdBuild && new LogStartCompilePlugin(), 139 | isProdBuild && 140 | isStatsBuild && 141 | new RsdoctorRspackPlugin({ 142 | reportDir: path.join(paths.build_dir, "rsdoctor"), 143 | features: ["plugins", "bundle"], 144 | supports: { 145 | generateTileGraph: true, 146 | }, 147 | }), 148 | ].filter(Boolean), 149 | resolve: { 150 | extensions: [".ts", ".js", ".json"], 151 | alias: { 152 | "lit/static-html$": "lit/static-html.js", 153 | "lit/decorators$": "lit/decorators.js", 154 | "lit/directive$": "lit/directive.js", 155 | "lit/directives/until$": "lit/directives/until.js", 156 | "lit/directives/class-map$": "lit/directives/class-map.js", 157 | "lit/directives/style-map$": "lit/directives/style-map.js", 158 | "lit/directives/if-defined$": "lit/directives/if-defined.js", 159 | "lit/directives/guard$": "lit/directives/guard.js", 160 | "lit/directives/cache$": "lit/directives/cache.js", 161 | "lit/directives/join$": "lit/directives/join.js", 162 | "lit/directives/repeat$": "lit/directives/repeat.js", 163 | "lit/directives/live$": "lit/directives/live.js", 164 | "lit/directives/keyed$": "lit/directives/keyed.js", 165 | "lit/polyfill-support$": "lit/polyfill-support.js", 166 | "@lit-labs/virtualizer/layouts/grid": "@lit-labs/virtualizer/layouts/grid.js", 167 | "@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver": 168 | "@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver.js", 169 | "@lit-labs/observers/resize-controller": "@lit-labs/observers/resize-controller.js", 170 | }, 171 | tsConfig: path.resolve(paths.root_dir, "tsconfig.json"), 172 | }, 173 | output: { 174 | module: latestBuild, 175 | filename: ({ chunk }) => 176 | !isProdBuild || isStatsBuild || dontHash.has(chunk.name) 177 | ? "[name].dev.js" 178 | : "[name].[contenthash].js", 179 | chunkFilename: isProdBuild && !isStatsBuild ? "[name].[contenthash].js" : "[name].js", 180 | assetModuleFilename: isProdBuild && !isStatsBuild ? "[id].[contenthash][ext]" : "[id][ext]", 181 | crossOriginLoading: "use-credentials", 182 | hashFunction: "xxhash64", 183 | path: outputPath, 184 | publicPath, 185 | // To silence warning in worker plugin 186 | globalObject: "self", 187 | }, 188 | experiments: { 189 | outputModule: true, 190 | topLevelAwait: true, 191 | }, 192 | }; 193 | }; 194 | 195 | const createKNXConfig = ({ isProdBuild, latestBuild }) => 196 | createRspackConfig(bundle.config.knx({ isProdBuild, latestBuild })); 197 | 198 | module.exports = { 199 | createKNXConfig, 200 | createRspackConfig, 201 | }; 202 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = { 4 | // Target directory for the build. 5 | buildDir: path.resolve(__dirname, "build"), 6 | nodeDir: path.resolve(__dirname, "../node_modules"), 7 | // Path where the Home Assistant frontend will be publicly available. 8 | publicPath: "/knx", 9 | }; 10 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /* eslint-disable import/no-extraneous-dependencies */ 4 | import unusedImports from "eslint-plugin-unused-imports"; 5 | import globals from "globals"; 6 | import path from "node:path"; 7 | import { fileURLToPath } from "node:url"; 8 | import js from "@eslint/js"; 9 | import { FlatCompat } from "@eslint/eslintrc"; 10 | import tseslint from "typescript-eslint"; 11 | import eslintConfigPrettier from "eslint-config-prettier"; 12 | import { configs as litConfigs } from "eslint-plugin-lit"; 13 | import { configs as wcConfigs } from "eslint-plugin-wc"; 14 | 15 | const _filename = fileURLToPath(import.meta.url); 16 | const _dirname = path.dirname(_filename); 17 | const compat = new FlatCompat({ 18 | baseDirectory: _dirname, 19 | recommendedConfig: js.configs.recommended, 20 | allConfig: js.configs.all, 21 | }); 22 | 23 | export default tseslint.config( 24 | ...compat.extends("airbnb-base", "plugin:lit-a11y/recommended"), 25 | eslintConfigPrettier, 26 | litConfigs["flat/all"], 27 | tseslint.configs.recommended, 28 | tseslint.configs.strict, 29 | tseslint.configs.stylistic, 30 | wcConfigs["flat/recommended"], 31 | { 32 | plugins: { 33 | "unused-imports": unusedImports, 34 | }, 35 | 36 | languageOptions: { 37 | globals: { 38 | ...globals.browser, 39 | __DEV__: false, 40 | __DEMO__: false, 41 | __BUILD__: false, 42 | __VERSION__: false, 43 | __STATIC_PATH__: false, 44 | __SUPERVISOR__: false, 45 | }, 46 | 47 | parser: tseslint.parser, 48 | ecmaVersion: 2020, 49 | sourceType: "module", 50 | 51 | parserOptions: { 52 | ecmaFeatures: { 53 | modules: true, 54 | }, 55 | }, 56 | }, 57 | 58 | settings: { 59 | "import/resolver": { 60 | webpack: { 61 | config: "./rspack.config.cjs", 62 | }, 63 | }, 64 | }, 65 | 66 | rules: { 67 | "class-methods-use-this": "off", 68 | "new-cap": "off", 69 | "prefer-template": "off", 70 | "object-shorthand": "off", 71 | "func-names": "off", 72 | "no-underscore-dangle": "off", 73 | strict: "off", 74 | "no-plusplus": "off", 75 | "no-bitwise": "error", 76 | "comma-dangle": "off", 77 | "vars-on-top": "off", 78 | "no-continue": "off", 79 | "no-param-reassign": "off", 80 | "no-multi-assign": "off", 81 | "no-console": "error", 82 | radix: "off", 83 | "no-alert": "off", 84 | "no-nested-ternary": "off", 85 | "prefer-destructuring": "off", 86 | "no-restricted-globals": [2, "event"], 87 | "prefer-promise-reject-errors": "off", 88 | "import/prefer-default-export": "off", 89 | "import/no-default-export": "off", 90 | "import/no-unresolved": "off", 91 | "import/no-cycle": "off", 92 | 93 | "import/extensions": [ 94 | "error", 95 | "ignorePackages", 96 | { 97 | ts: "never", 98 | js: "never", 99 | }, 100 | ], 101 | 102 | "no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"], 103 | "object-curly-newline": "off", 104 | "default-case": "off", 105 | "wc/no-self-class": "off", 106 | "no-shadow": "off", 107 | "@typescript-eslint/camelcase": "off", 108 | "@typescript-eslint/ban-ts-comment": "off", 109 | "@typescript-eslint/no-use-before-define": "off", 110 | "@typescript-eslint/no-non-null-assertion": "off", 111 | "@typescript-eslint/no-explicit-any": "off", 112 | "@typescript-eslint/explicit-function-return-type": "off", 113 | "@typescript-eslint/explicit-module-boundary-types": "off", 114 | "@typescript-eslint/no-shadow": ["error"], 115 | 116 | "@typescript-eslint/naming-convention": [ 117 | "error", 118 | { 119 | selector: ["objectLiteralProperty", "objectLiteralMethod"], 120 | format: null, 121 | }, 122 | { 123 | selector: ["variable"], 124 | format: ["camelCase", "snake_case", "UPPER_CASE"], 125 | leadingUnderscore: "allow", 126 | trailingUnderscore: "allow", 127 | }, 128 | { 129 | selector: ["variable"], 130 | modifiers: ["exported"], 131 | format: ["camelCase", "PascalCase", "UPPER_CASE"], 132 | }, 133 | { 134 | selector: "typeLike", 135 | format: ["PascalCase"], 136 | }, 137 | { 138 | selector: "method", 139 | modifiers: ["public"], 140 | format: ["camelCase"], 141 | leadingUnderscore: "forbid", 142 | }, 143 | { 144 | selector: "method", 145 | modifiers: ["private"], 146 | format: ["camelCase"], 147 | leadingUnderscore: "require", 148 | }, 149 | ], 150 | 151 | "@typescript-eslint/no-unused-vars": [ 152 | "error", 153 | { 154 | args: "all", 155 | argsIgnorePattern: "^_", 156 | caughtErrors: "all", 157 | caughtErrorsIgnorePattern: "^_", 158 | destructuredArrayIgnorePattern: "^_", 159 | varsIgnorePattern: "^_", 160 | ignoreRestSiblings: true, 161 | }, 162 | ], 163 | 164 | "unused-imports/no-unused-imports": "error", 165 | "lit/attribute-names": "error", 166 | "lit/attribute-value-entities": "off", 167 | "lit/no-template-map": "off", 168 | "lit/no-native-attributes": "error", 169 | "lit/no-this-assign-in-render": "error", 170 | "lit-a11y/click-events-have-key-events": ["off"], 171 | "lit-a11y/no-autofocus": "off", 172 | "lit-a11y/alt-text": "error", 173 | "lit-a11y/anchor-is-valid": "error", 174 | "lit-a11y/role-has-required-aria-attrs": "error", 175 | "@typescript-eslint/consistent-type-imports": "error", 176 | "@typescript-eslint/no-import-type-side-effects": "error", 177 | camelcase: "off", 178 | "@typescript-eslint/no-dynamic-delete": "off", 179 | "@typescript-eslint/no-empty-object-type": [ 180 | "error", 181 | { 182 | allowInterfaces: "always", 183 | allowObjectTypes: "always", 184 | }, 185 | ], 186 | "no-use-before-define": "off", 187 | }, 188 | }, 189 | ); 190 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | import { availableParallelism } from "node:os"; 2 | import "./build-scripts/gulp/index.mjs"; 3 | 4 | process.env.UV_THREADPOOL_SIZE = availableParallelism(); 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "knx-frontend", 3 | "version": "0.0.1", 4 | "description": "Home Assistant custom panel for the KNX integration", 5 | "source": "src/entrypoint.ts", 6 | "main": "build/entrypoint.js", 7 | "scripts": { 8 | "develop": "./script/develop", 9 | "build": "./script/build", 10 | "lint:eslint": "eslint --flag unstable_config_lookup_from_file \"./src/**/*.{js,ts,html}\" --ignore-pattern .gitignore --max-warnings=0", 11 | "format:eslint": "eslint --flag unstable_config_lookup_from_file \"./src/**/*.{js,ts,html}\" --fix --ignore-pattern .gitignore", 12 | "lint:prettier": "prettier \"./src/**/*.{js,ts,json,css,md}\" --check", 13 | "format:prettier": "prettier \"./src/**/*.{js,ts,json,css,md}\" --write", 14 | "lint:types": "tsc --project tsconfig.json", 15 | "lint:lit": "lit-analyzer \"./src/**/*.ts\"", 16 | "lint": "yarn run lint:eslint && yarn run lint:prettier && yarn run lint:types && yarn run lint:lit", 17 | "format": "yarn run format:eslint && yarn run format:prettier", 18 | "postinstall": "husky", 19 | "prepack": "pinst --disable", 20 | "postpack": "pinst --enable" 21 | }, 22 | "repository": "https://github.com/XKNX/knx-frontend.git", 23 | "author": "Marvin Wichmann ", 24 | "license": "MIT", 25 | "type": "module", 26 | "dependenciesOverride": { 27 | "compare-versions": "6.1.0" 28 | }, 29 | "resolutionsOverride": {}, 30 | "dependencies": { 31 | "@babel/runtime": "7.27.1", 32 | "@braintree/sanitize-url": "7.1.1", 33 | "@codemirror/autocomplete": "6.18.6", 34 | "@codemirror/commands": "6.8.1", 35 | "@codemirror/language": "6.11.0", 36 | "@codemirror/legacy-modes": "6.5.1", 37 | "@codemirror/search": "6.5.11", 38 | "@codemirror/state": "6.5.2", 39 | "@codemirror/view": "6.36.8", 40 | "@egjs/hammerjs": "2.0.17", 41 | "@formatjs/intl-datetimeformat": "6.18.0", 42 | "@formatjs/intl-displaynames": "6.8.11", 43 | "@formatjs/intl-durationformat": "0.7.4", 44 | "@formatjs/intl-getcanonicallocales": "2.5.5", 45 | "@formatjs/intl-listformat": "7.7.11", 46 | "@formatjs/intl-locale": "4.2.11", 47 | "@formatjs/intl-numberformat": "8.15.4", 48 | "@formatjs/intl-pluralrules": "5.4.4", 49 | "@formatjs/intl-relativetimeformat": "11.4.11", 50 | "@fullcalendar/core": "6.1.17", 51 | "@fullcalendar/daygrid": "6.1.17", 52 | "@fullcalendar/interaction": "6.1.17", 53 | "@fullcalendar/list": "6.1.17", 54 | "@fullcalendar/luxon3": "6.1.17", 55 | "@fullcalendar/timegrid": "6.1.17", 56 | "@lezer/highlight": "1.2.1", 57 | "@lit-labs/motion": "1.0.8", 58 | "@lit-labs/observers": "2.0.5", 59 | "@lit-labs/virtualizer": "2.1.0", 60 | "@lit/context": "1.1.5", 61 | "@lit/reactive-element": "2.1.0", 62 | "@material/chips": "=14.0.0-canary.53b3cad2f.0", 63 | "@material/data-table": "=14.0.0-canary.53b3cad2f.0", 64 | "@material/mwc-base": "0.27.0", 65 | "@material/mwc-button": "0.27.0", 66 | "@material/mwc-checkbox": "0.27.0", 67 | "@material/mwc-dialog": "0.27.0", 68 | "@material/mwc-drawer": "0.27.0", 69 | "@material/mwc-fab": "0.27.0", 70 | "@material/mwc-floating-label": "0.27.0", 71 | "@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/homeassistant-frontend/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch", 72 | "@material/mwc-icon-button": "0.27.0", 73 | "@material/mwc-linear-progress": "0.27.0", 74 | "@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/homeassistant-frontend/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch", 75 | "@material/mwc-menu": "0.27.0", 76 | "@material/mwc-radio": "0.27.0", 77 | "@material/mwc-select": "0.27.0", 78 | "@material/mwc-snackbar": "0.27.0", 79 | "@material/mwc-switch": "0.27.0", 80 | "@material/mwc-textarea": "0.27.0", 81 | "@material/mwc-textfield": "0.27.0", 82 | "@material/mwc-top-app-bar": "0.27.0", 83 | "@material/mwc-top-app-bar-fixed": "0.27.0", 84 | "@material/top-app-bar": "=14.0.0-canary.53b3cad2f.0", 85 | "@material/web": "2.3.0", 86 | "@mdi/js": "7.4.47", 87 | "@mdi/svg": "7.4.47", 88 | "@replit/codemirror-indentation-markers": "6.5.3", 89 | "@shoelace-style/shoelace": "2.20.1", 90 | "@swc/helpers": "0.5.17", 91 | "@thomasloven/round-slider": "0.6.0", 92 | "@tsparticles/engine": "3.8.1", 93 | "@tsparticles/preset-links": "3.2.0", 94 | "@vaadin/combo-box": "24.7.7", 95 | "@vaadin/vaadin-themable-mixin": "24.7.7", 96 | "@vibrant/color": "4.0.0", 97 | "@vue/web-component-wrapper": "1.3.0", 98 | "@webcomponents/scoped-custom-element-registry": "0.0.10", 99 | "@webcomponents/webcomponentsjs": "2.8.0", 100 | "app-datepicker": "5.1.1", 101 | "barcode-detector": "3.0.4", 102 | "color-name": "2.0.0", 103 | "comlink": "4.4.2", 104 | "compare-versions": "6.1.0", 105 | "core-js": "3.42.0", 106 | "cropperjs": "1.6.2", 107 | "date-fns": "4.1.0", 108 | "date-fns-tz": "3.2.0", 109 | "deep-clone-simple": "1.1.1", 110 | "deep-freeze": "0.0.1", 111 | "dialog-polyfill": "0.5.6", 112 | "echarts": "5.6.0", 113 | "element-internals-polyfill": "3.0.2", 114 | "fuse.js": "7.1.0", 115 | "google-timezones-json": "1.2.0", 116 | "gulp-zopfli-green": "6.0.2", 117 | "hls.js": "1.6.2", 118 | "home-assistant-js-websocket": "9.5.0", 119 | "idb-keyval": "6.2.2", 120 | "intl-messageformat": "10.7.16", 121 | "js-yaml": "4.1.0", 122 | "leaflet": "1.9.4", 123 | "leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./homeassistant-frontend/.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch", 124 | "leaflet.markercluster": "1.5.3", 125 | "lit": "3.3.0", 126 | "lit-html": "3.3.0", 127 | "luxon": "3.6.1", 128 | "marked": "15.0.12", 129 | "memoize-one": "6.0.0", 130 | "node-vibrant": "4.0.3", 131 | "object-hash": "3.0.0", 132 | "punycode": "2.3.1", 133 | "qr-scanner": "1.4.2", 134 | "qrcode": "1.5.4", 135 | "roboto-fontface": "0.10.0", 136 | "rrule": "2.8.1", 137 | "sortablejs": "patch:sortablejs@npm%3A1.15.6#~/homeassistant-frontend/.yarn/patches/sortablejs-npm-1.15.6-3235a8f83b.patch", 138 | "stacktrace-js": "2.0.2", 139 | "superstruct": "2.0.2", 140 | "tinykeys": "3.0.0", 141 | "ua-parser-js": "2.0.3", 142 | "vis-data": "7.1.9", 143 | "vue": "2.7.16", 144 | "vue2-daterange-picker": "0.6.8", 145 | "weekstart": "2.0.0", 146 | "workbox-cacheable-response": "7.3.0", 147 | "workbox-core": "7.3.0", 148 | "workbox-expiration": "7.3.0", 149 | "workbox-precaching": "7.3.0", 150 | "workbox-routing": "7.3.0", 151 | "workbox-strategies": "7.3.0", 152 | "xss": "1.0.15" 153 | }, 154 | "devDependencies": { 155 | "@babel/core": "7.27.1", 156 | "@babel/helper-define-polyfill-provider": "0.6.4", 157 | "@babel/plugin-transform-runtime": "7.27.1", 158 | "@babel/preset-env": "7.27.2", 159 | "@bundle-stats/plugin-webpack-filter": "4.20.1", 160 | "@lokalise/node-api": "14.7.0", 161 | "@octokit/auth-oauth-device": "8.0.1", 162 | "@octokit/plugin-retry": "8.0.1", 163 | "@octokit/rest": "21.1.1", 164 | "@rsdoctor/rspack-plugin": "1.1.2", 165 | "@rspack/cli": "1.3.11", 166 | "@rspack/core": "1.3.11", 167 | "@types/babel__plugin-transform-runtime": "7.9.5", 168 | "@types/chromecast-caf-receiver": "6.0.21", 169 | "@types/chromecast-caf-sender": "1.0.11", 170 | "@types/color-name": "2.0.0", 171 | "@types/glob": "8.1.0", 172 | "@types/html-minifier-terser": "7.0.2", 173 | "@types/js-yaml": "4.0.9", 174 | "@types/leaflet": "1.9.18", 175 | "@types/leaflet-draw": "1.0.12", 176 | "@types/leaflet.markercluster": "1.5.5", 177 | "@types/lodash.merge": "4.6.9", 178 | "@types/luxon": "3.6.2", 179 | "@types/mocha": "10.0.10", 180 | "@types/qrcode": "1.5.5", 181 | "@types/sortablejs": "1.15.8", 182 | "@types/tar": "6.1.13", 183 | "@types/ua-parser-js": "0.7.39", 184 | "@types/webspeechapi": "0.0.29", 185 | "@vitest/coverage-v8": "3.1.4", 186 | "babel-loader": "10.0.0", 187 | "babel-plugin-template-html-minifier": "4.1.0", 188 | "browserslist-useragent-regexp": "4.1.3", 189 | "del": "8.0.0", 190 | "eslint": "9.27.0", 191 | "eslint-config-airbnb-base": "15.0.0", 192 | "eslint-config-prettier": "10.1.5", 193 | "eslint-import-resolver-webpack": "0.13.10", 194 | "eslint-plugin-import": "2.31.0", 195 | "eslint-plugin-lit": "2.1.1", 196 | "eslint-plugin-lit-a11y": "4.1.4", 197 | "eslint-plugin-unused-imports": "4.1.4", 198 | "eslint-plugin-wc": "3.0.1", 199 | "fancy-log": "2.0.0", 200 | "fs-extra": "11.3.0", 201 | "glob": "11.0.2", 202 | "gulp": "5.0.0", 203 | "gulp-brotli": "3.0.0", 204 | "gulp-json-transform": "0.5.0", 205 | "gulp-rename": "2.0.0", 206 | "html-minifier-terser": "7.2.0", 207 | "husky": "9.1.7", 208 | "jsdom": "26.1.0", 209 | "jszip": "3.10.1", 210 | "lint-staged": "15.5.2", 211 | "lit-analyzer": "2.0.3", 212 | "lodash.merge": "4.6.2", 213 | "lodash.template": "4.5.0", 214 | "map-stream": "0.0.7", 215 | "pinst": "3.0.0", 216 | "prettier": "3.5.3", 217 | "rspack-manifest-plugin": "5.0.3", 218 | "serve": "14.2.4", 219 | "sinon": "20.0.0", 220 | "tar": "7.4.3", 221 | "terser-webpack-plugin": "5.3.14", 222 | "ts-lit-plugin": "2.0.2", 223 | "typescript": "5.8.3", 224 | "typescript-eslint": "8.32.1", 225 | "vite-tsconfig-paths": "5.1.4", 226 | "vitest": "3.1.4", 227 | "webpack-stats-plugin": "1.1.3", 228 | "webpackbar": "7.0.0", 229 | "workbox-build": "patch:workbox-build@npm%3A7.1.1#~/homeassistant-frontend/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch" 230 | }, 231 | "resolutions": { 232 | "@material/mwc-button@^0.25.3": "^0.27.0", 233 | "lit": "3.3.0", 234 | "lit-html": "3.3.0", 235 | "clean-css": "5.3.3", 236 | "@lit/reactive-element": "2.1.0", 237 | "@fullcalendar/daygrid": "6.1.17", 238 | "globals": "16.1.0", 239 | "tslib": "2.8.1", 240 | "@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/homeassistant-frontend/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch" 241 | }, 242 | "packageManager": "yarn@4.9.1" 243 | } 244 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=77.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "knx_frontend" 7 | license = { text = "MIT" } 8 | description = "KNX panel for Home Assistant" 9 | keywords = ["Home Assistant", "KNX"] 10 | readme = "README.md" 11 | authors = [ 12 | { name = "Marvin Wichmann", email = "me@marvin-wichmann.de" }, 13 | { name = "Matthias Alphart", email = "farmio@alphart.net" }, 14 | ] 15 | requires-python = ">=3.11.0" 16 | dynamic = ["version"] 17 | 18 | [project.urls] 19 | Repository = "https://github.com/XKNX/knx-frontend.git" 20 | 21 | [tool.setuptools.dynamic] 22 | version = { file = "VERSION" } 23 | 24 | [tool.setuptools.packages.find] 25 | include = ["knx_frontend*"] 26 | 27 | [tool.mypy] 28 | python_version = "3.9" 29 | show_error_codes = true 30 | strict = true 31 | -------------------------------------------------------------------------------- /rspack.config.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | // Needs to remain CommonJS until eslint-import-resolver-webpack supports ES modules 3 | const { createKNXConfig } = require("./build-scripts/rspack.cjs"); 4 | const { isProdBuild, isStatsBuild } = require("./build-scripts/env.cjs"); 5 | 6 | module.exports = createKNXConfig({ 7 | isProdBuild: isProdBuild(), 8 | isStatsBuild: isStatsBuild(), 9 | latestBuild: true, 10 | }); 11 | -------------------------------------------------------------------------------- /screenshots/bus_monitor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKNX/knx-frontend/369c0069d61842aa5f38588c12a259a5558175ae/screenshots/bus_monitor.png -------------------------------------------------------------------------------- /screenshots/info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKNX/knx-frontend/369c0069d61842aa5f38588c12a259a5558175ae/screenshots/info.png -------------------------------------------------------------------------------- /screenshots/project.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKNX/knx-frontend/369c0069d61842aa5f38588c12a259a5558175ae/screenshots/project.png -------------------------------------------------------------------------------- /script/bootstrap: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Resolve all frontend dependencies that the application requires to develop. 3 | 4 | # Stop on errors 5 | set -e 6 | 7 | cd "$(dirname "$0")/.." 8 | 9 | if [ ! -d "./homeassistant-frontend/src" ]; then 10 | cd homeassistant-frontend 11 | git submodule init 12 | git submodule update 13 | cd .. 14 | fi 15 | 16 | yarn install -------------------------------------------------------------------------------- /script/build: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Builds the frontend for production 3 | 4 | # Stop on errors 5 | set -e 6 | 7 | cd "$(dirname "$0")/.." 8 | 9 | ./node_modules/.bin/gulp build-knx 10 | -------------------------------------------------------------------------------- /script/develop: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Run the frontend development server 3 | 4 | # Stop on errors 5 | set -e 6 | 7 | cd "$(dirname "$0")/.." 8 | 9 | if [ ! -d "./node_modules" ]; then 10 | echo "Directory /node_modules DOES NOT exists." 11 | echo "Running yarn install" 12 | yarn install 13 | fi 14 | 15 | ./node_modules/.bin/gulp develop-knx 16 | -------------------------------------------------------------------------------- /script/merge_requirements.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | 3 | const rawPackageCore = fs.readFileSync("./homeassistant-frontend/package.json"); 4 | const rawPackageKnx = fs.readFileSync("./package.json"); 5 | 6 | const packageCore = JSON.parse(rawPackageCore); 7 | const packageKnx = JSON.parse(rawPackageKnx); 8 | 9 | const _replaceYarnPath = (path) => path.replace(/\.yarn\//g, "homeassistant-frontend/.yarn/"); 10 | 11 | const subdir_dependencies = Object.fromEntries( 12 | Object.entries(packageCore.dependencies).map(([key, value]) => [key, _replaceYarnPath(value)]), 13 | ); 14 | 15 | const subdir_dev_dependencies = Object.fromEntries( 16 | Object.entries(packageCore.devDependencies).map(([key, value]) => [key, _replaceYarnPath(value)]), 17 | ); 18 | 19 | const subdir_resolutions = Object.fromEntries( 20 | Object.entries(packageCore.resolutions).map(([key, value]) => [key, _replaceYarnPath(value)]), 21 | ); 22 | 23 | fs.writeFileSync( 24 | "./package.json", 25 | JSON.stringify( 26 | { 27 | ...packageKnx, 28 | dependencies: { ...subdir_dependencies, ...packageKnx.dependenciesOverride }, 29 | devDependencies: { 30 | ...subdir_dev_dependencies, 31 | ...packageKnx.devDependenciesOverride, 32 | }, 33 | resolutions: { ...subdir_resolutions, ...packageKnx.resolutionsOverride }, 34 | packageManager: packageCore.packageManager, 35 | }, 36 | null, 37 | 2, 38 | ), 39 | ); 40 | 41 | const yarnRcCore = fs.readFileSync("./homeassistant-frontend/.yarnrc.yml", "utf8"); 42 | const yarnRcKnx = yarnRcCore.replace(/\.yarn\//g, "homeassistant-frontend/.yarn/"); 43 | fs.writeFileSync("./.yarnrc.yml", yarnRcKnx); 44 | -------------------------------------------------------------------------------- /script/upgrade-frontend: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Update homeassistant-frontend submodule. 3 | # Pass a specific tag to update to that version. If omitted it will update to the latest tag. 4 | 5 | # Stop on errors 6 | set -e 7 | 8 | cd "$(dirname "$0")/.." 9 | 10 | echo "Updating frontend submodule" 11 | cd homeassistant-frontend 12 | 13 | previousTag=$(git describe --tags) 14 | git fetch --tags 15 | if [ "$1" != "" ]; then 16 | tagSource="required" 17 | newTag=$1 18 | else 19 | tagSource="latest" 20 | newTag=$(git describe --tags `git rev-list --tags --max-count=1`) 21 | fi 22 | if [ "$previousTag" == "$newTag" ]; then 23 | echo "Skipping update. Already on $tagSource tag: $newTag" 24 | exit 0 25 | fi 26 | 27 | git checkout $newTag 28 | # This has cost me a couple of hours already, so let's make sure node_modules in submodule is gone 29 | rm -rf node_modules 30 | 31 | cd .. 32 | 33 | # KNX Frontend 34 | 35 | echo "Copying browserslist" 36 | cp homeassistant-frontend/.browserslistrc . 37 | 38 | echo "Merging requirements" 39 | node ./script/merge_requirements.js 40 | 41 | echo "Installing modules" 42 | yarn install 43 | yarn dedupe 44 | 45 | echo "\nUpdated HA frontend from $previousTag to $newTag" 46 | echo "Here is the diff: https://github.com/home-assistant/frontend/compare/$previousTag..$newTag" -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | # Setuptools v62.3 doesn't support editable installs with just 'pyproject.toml' (PEP 660). 2 | # Keep this file until it does! 3 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- 1 | """KNX Frontend.""" 2 | 3 | from typing import Final 4 | 5 | from .constants import FILE_HASH 6 | 7 | 8 | def locate_dir() -> str: 9 | """Return the location of the frontend files.""" 10 | return __path__[0] 11 | 12 | 13 | # Filename of the entrypoint.js to import the panel 14 | entrypoint_js: Final = f"entrypoint.{FILE_HASH}.js" 15 | 16 | # The webcomponent name that loads the panel (main.ts) 17 | webcomponent_name: Final = "knx-frontend" 18 | 19 | is_dev_build: Final = FILE_HASH == "dev" 20 | is_prod_build: Final = not is_dev_build 21 | -------------------------------------------------------------------------------- /src/components/knx-configure-entity-options.ts: -------------------------------------------------------------------------------- 1 | import { html, nothing } from "lit"; 2 | 3 | import "@ha/components/ha-alert"; 4 | import "@ha/components/ha-card"; 5 | import "@ha/components/ha-expansion-panel"; 6 | import "@ha/components/ha-selector/ha-selector-select"; 7 | import "@ha/components/ha-selector/ha-selector-text"; 8 | import type { HomeAssistant } from "@ha/types"; 9 | 10 | import "./knx-sync-state-selector-row"; 11 | import "./knx-device-picker"; 12 | 13 | import { deviceFromIdentifier } from "../utils/device"; 14 | import type { BaseEntityData, ErrorDescription } from "../types/entity_data"; 15 | 16 | export const renderConfigureEntityCard = ( 17 | hass: HomeAssistant, 18 | entityConfig: Partial, 19 | updateConfig: (ev: CustomEvent) => void, 20 | errors?: ErrorDescription[], 21 | ) => { 22 | const device = entityConfig.device_info 23 | ? deviceFromIdentifier(hass, entityConfig.device_info) 24 | : undefined; 25 | const deviceName = device ? (device.name_by_user ?? device.name) : ""; 26 | // currently only baseError is possible, others shouldn't be possible due to selectors / optional 27 | const entityBaseError = errors?.find((err) => (err.path ? err.path.length === 0 : true)); 28 | 29 | return html` 30 | 31 |

Entity configuration

32 |

Home Assistant specific settings.

33 | ${errors 34 | ? entityBaseError 35 | ? html`` 39 | : nothing 40 | : nothing} 41 | 47 | 54 | 66 | 67 | 68 | 88 | 89 |
90 | `; 91 | }; 92 | -------------------------------------------------------------------------------- /src/components/knx-device-picker.ts: -------------------------------------------------------------------------------- 1 | /** This is a mix of ha-device-picker and ha-area-picker to allow for 2 | * creation of new devices and include (KNX) devices without entities. 3 | * Unlike the ha-device-picker or selector, its value is the device identifier 4 | * (second tuple item), not the device id. 5 | * */ 6 | import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; 7 | import type { PropertyValues, TemplateResult } from "lit"; 8 | import { LitElement, html, nothing } from "lit"; 9 | import { customElement, query, property, state } from "lit/decorators"; 10 | import { classMap } from "lit/directives/class-map"; 11 | 12 | import memoizeOne from "memoize-one"; 13 | 14 | import "@ha/components/ha-combo-box"; 15 | import "@ha/components/ha-list-item"; 16 | 17 | import "../dialogs/knx-device-create-dialog"; 18 | 19 | import { fireEvent } from "@ha/common/dom/fire_event"; 20 | import type { ScorableTextItem } from "@ha/common/string/filter/sequence-matching"; 21 | import { fuzzyFilterSort } from "@ha/common/string/filter/sequence-matching"; 22 | import { stringCompare } from "@ha/common/string/compare"; 23 | 24 | import type { HomeAssistant, ValueChangedEvent } from "@ha/types"; 25 | import type { AreaRegistryEntry } from "@ha/data/area_registry"; 26 | import type { DeviceRegistryEntry } from "@ha/data/device_registry"; 27 | import type { HaComboBox } from "@ha/components/ha-combo-box"; 28 | 29 | import { knxDevices, getKnxDeviceIdentifier } from "../utils/device"; 30 | 31 | interface Device { 32 | name: string; 33 | area: string; 34 | id: string; 35 | identifier?: string; 36 | } 37 | 38 | type ScorableDevice = ScorableTextItem & Device; 39 | 40 | const rowRenderer: ComboBoxLitRenderer = (item) => 41 | html` 45 | ${item.name} 46 | ${item.area} 47 | `; 48 | 49 | @customElement("knx-device-picker") 50 | class KnxDevicePicker extends LitElement { 51 | @property({ attribute: false }) public hass!: HomeAssistant; 52 | 53 | @property() public label?: string; 54 | 55 | @property() public helper?: string; 56 | 57 | @property() public value?: string; 58 | 59 | @state() private _opened?: boolean; 60 | 61 | @query("ha-combo-box", true) public comboBox!: HaComboBox; 62 | 63 | @state() private _showCreateDeviceDialog = false; 64 | 65 | // value is the knx identifier (device_info), not the device id 66 | private _deviceId?: string; 67 | 68 | private _suggestion?: string; 69 | 70 | private _init = false; 71 | 72 | private _getDevices = memoizeOne( 73 | ( 74 | devices: DeviceRegistryEntry[], 75 | areas: Record, 76 | ): ScorableDevice[] => { 77 | const outputDevices = devices.map((device) => { 78 | const name = device.name_by_user ?? device.name ?? ""; 79 | return { 80 | id: device.id, 81 | identifier: getKnxDeviceIdentifier(device), 82 | name: name, 83 | area: 84 | device.area_id && areas[device.area_id] 85 | ? areas[device.area_id].name 86 | : this.hass.localize("ui.components.device-picker.no_area"), 87 | strings: [name || ""], 88 | }; 89 | }); 90 | return [ 91 | { 92 | id: "add_new", 93 | name: "Add new device…", 94 | area: "", 95 | strings: [], 96 | }, 97 | ...outputDevices.sort((a, b) => 98 | stringCompare(a.name || "", b.name || "", this.hass.locale.language), 99 | ), 100 | ]; 101 | }, 102 | ); 103 | 104 | private async _addDevice(device: DeviceRegistryEntry) { 105 | const deviceEntries = [...knxDevices(this.hass), device]; 106 | const devices = this._getDevices(deviceEntries, this.hass.areas); 107 | this.comboBox.items = devices; 108 | this.comboBox.filteredItems = devices; 109 | await this.updateComplete; 110 | await this.comboBox.updateComplete; 111 | } 112 | 113 | public async open() { 114 | await this.updateComplete; 115 | await this.comboBox?.open(); 116 | } 117 | 118 | public async focus() { 119 | await this.updateComplete; 120 | await this.comboBox?.focus(); 121 | } 122 | 123 | protected updated(changedProps: PropertyValues) { 124 | if ((!this._init && this.hass) || (this._init && changedProps.has("_opened") && this._opened)) { 125 | this._init = true; 126 | const devices = this._getDevices(knxDevices(this.hass), this.hass.areas); 127 | const deviceId = this.value 128 | ? devices.find((d) => d.identifier === this.value)?.id 129 | : undefined; 130 | this.comboBox.value = deviceId; 131 | this._deviceId = deviceId; 132 | this.comboBox.items = devices; 133 | this.comboBox.filteredItems = devices; 134 | } 135 | } 136 | 137 | render(): TemplateResult { 138 | return html` 139 | 154 | ${this._showCreateDeviceDialog ? this._renderCreateDeviceDialog() : nothing} 155 | `; 156 | } 157 | 158 | private _filterChanged(ev: CustomEvent): void { 159 | const target = ev.target as HaComboBox; 160 | const filterString = ev.detail.value; 161 | if (!filterString) { 162 | this.comboBox.filteredItems = this.comboBox.items; 163 | return; 164 | } 165 | 166 | const filteredItems = fuzzyFilterSort(filterString, target.items || []); 167 | this._suggestion = filterString; 168 | this.comboBox.filteredItems = [ 169 | ...filteredItems, 170 | { 171 | id: "add_new_suggestion", 172 | name: `Add new device '${this._suggestion}'`, 173 | }, 174 | ]; 175 | } 176 | 177 | private _openedChanged(ev: ValueChangedEvent) { 178 | this._opened = ev.detail.value; 179 | } 180 | 181 | private _deviceChanged(ev: ValueChangedEvent) { 182 | ev.stopPropagation(); 183 | let newValue = ev.detail.value; 184 | 185 | if (newValue === "no_devices") { 186 | newValue = ""; 187 | } 188 | 189 | if (!["add_new_suggestion", "add_new"].includes(newValue)) { 190 | if (newValue !== this._deviceId) { 191 | this._setValue(newValue); 192 | } 193 | return; 194 | } 195 | 196 | (ev.target as any).value = this._deviceId; 197 | this._openCreateDeviceDialog(); 198 | } 199 | 200 | private _setValue(deviceId: string | undefined) { 201 | const device: Device | undefined = this.comboBox.items!.find((d) => d.id === deviceId); 202 | const identifier = device?.identifier; 203 | this.value = identifier; 204 | this._deviceId = device?.id; 205 | setTimeout(() => { 206 | fireEvent(this, "value-changed", { value: identifier }); 207 | fireEvent(this, "change"); 208 | }, 0); 209 | } 210 | 211 | private _renderCreateDeviceDialog() { 212 | return html` 213 | 218 | `; 219 | } 220 | 221 | private _openCreateDeviceDialog() { 222 | this._showCreateDeviceDialog = true; 223 | } 224 | 225 | private async _closeCreateDeviceDialog(ev: CustomEvent) { 226 | const newDevice: DeviceRegistryEntry | undefined = ev.detail.newDevice; 227 | if (newDevice) { 228 | await this._addDevice(newDevice); 229 | } else { 230 | this.comboBox.setInputValue(""); 231 | } 232 | this._setValue(newDevice?.id); 233 | this._suggestion = undefined; 234 | this._showCreateDeviceDialog = false; 235 | } 236 | } 237 | 238 | declare global { 239 | interface HTMLElementTagNameMap { 240 | "knx-device-picker": KnxDevicePicker; 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /src/components/knx-dpt-selector.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css, nothing } from "lit"; 2 | import { customElement, property } from "lit/decorators"; 3 | 4 | import "@ha/components/ha-formfield"; 5 | import "@ha/components/ha-radio"; 6 | import { fireEvent } from "@ha/common/dom/fire_event"; 7 | 8 | import type { DPTOption } from "../utils/schema"; 9 | 10 | @customElement("knx-dpt-selector") 11 | class KnxDptSelector extends LitElement { 12 | @property({ type: Array }) public options!: DPTOption[]; 13 | 14 | @property() public value?: string; 15 | 16 | @property() public label?: string; 17 | 18 | @property({ type: Boolean }) public disabled = false; 19 | 20 | @property({ type: Boolean, reflect: true }) public invalid = false; 21 | 22 | @property({ attribute: false }) public invalidMessage?: string; 23 | 24 | render() { 25 | return html` 26 |
27 | ${this.label ?? nothing} 28 | ${this.options.map( 29 | (item: DPTOption) => html` 30 |
31 | 37 | 41 |
42 | `, 43 | )} 44 | ${this.invalidMessage 45 | ? html`

${this.invalidMessage}

` 46 | : nothing} 47 |
48 | `; 49 | } 50 | 51 | private _valueChanged(ev) { 52 | ev.stopPropagation(); 53 | const value = ev.target.value; 54 | if (this.disabled || value === undefined || value === (this.value ?? "")) { 55 | return; 56 | } 57 | fireEvent(this, "value-changed", { value: value }); 58 | } 59 | 60 | static styles = [ 61 | css` 62 | :host([invalid]) div { 63 | color: var(--error-color); 64 | } 65 | 66 | .formfield { 67 | display: flex; 68 | align-items: center; 69 | } 70 | 71 | label { 72 | min-width: 200px; /* to make it easier to click */ 73 | } 74 | 75 | p { 76 | pointer-events: none; 77 | color: var(--primary-text-color); 78 | margin: 0px; 79 | } 80 | 81 | .secondary { 82 | padding-top: 4px; 83 | font-family: var( 84 | --mdc-typography-body2-font-family, 85 | var(--mdc-typography-font-family, Roboto, sans-serif) 86 | ); 87 | -webkit-font-smoothing: antialiased; 88 | font-size: var(--mdc-typography-body2-font-size, 0.875rem); 89 | font-weight: var(--mdc-typography-body2-font-weight, 400); 90 | line-height: normal; 91 | color: var(--secondary-text-color); 92 | } 93 | 94 | .invalid-message { 95 | font-size: 0.75rem; 96 | color: var(--error-color); 97 | padding-left: 16px; 98 | } 99 | `, 100 | ]; 101 | } 102 | 103 | declare global { 104 | interface HTMLElementTagNameMap { 105 | "knx-dpt-selector": KnxDptSelector; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/components/knx-project-tree-view.ts: -------------------------------------------------------------------------------- 1 | import type { TemplateResult } from "lit"; 2 | import { css, html, LitElement, nothing } from "lit"; 3 | import { customElement, property, state } from "lit/decorators"; 4 | import { classMap } from "lit/directives/class-map"; 5 | 6 | import { fireEvent } from "@ha/common/dom/fire_event"; 7 | 8 | import type { GroupRange, KNXProject } from "../types/websocket"; 9 | import { KNXLogger } from "../tools/knx-logger"; 10 | 11 | const logger = new KNXLogger("knx-project-tree-view"); 12 | 13 | declare global { 14 | // for fire event 15 | interface HASSDomEvents { 16 | "knx-group-range-selection-changed": GroupRangeSelectionChangedEvent; 17 | } 18 | } 19 | 20 | export interface GroupRangeSelectionChangedEvent { 21 | groupAddresses: string[]; 22 | } 23 | 24 | interface RangeInfo { 25 | selected: boolean; 26 | groupAddresses: string[]; 27 | } 28 | 29 | @customElement("knx-project-tree-view") 30 | export class KNXProjectTreeView extends LitElement { 31 | @property({ attribute: false }) data!: KNXProject; 32 | 33 | @property({ attribute: false }) multiselect = false; 34 | 35 | @state() private _selectableRanges: Record = {}; 36 | 37 | connectedCallback() { 38 | super.connectedCallback(); 39 | 40 | const initSelectableRanges = (data: Record) => { 41 | Object.entries(data).forEach(([key, groupRange]) => { 42 | if (groupRange.group_addresses.length > 0) { 43 | this._selectableRanges[key] = { 44 | selected: false, 45 | groupAddresses: groupRange.group_addresses, 46 | }; 47 | } 48 | initSelectableRanges(groupRange.group_ranges); 49 | }); 50 | }; 51 | initSelectableRanges(this.data.group_ranges); 52 | logger.debug("ranges", this._selectableRanges); 53 | } 54 | 55 | protected render(): TemplateResult { 56 | return html`
${this._recurseData(this.data.group_ranges)}
`; 57 | } 58 | 59 | protected _recurseData(data: Record, level = 0): TemplateResult { 60 | const childTemplates = Object.entries(data).map(([key, groupRange]) => { 61 | const hasSubRange = Object.keys(groupRange.group_ranges).length > 0; 62 | const empty = !(hasSubRange || groupRange.group_addresses.length > 0); 63 | if (empty) { 64 | return nothing; 65 | } 66 | const selectable = key in this._selectableRanges; 67 | const selected = selectable ? this._selectableRanges[key].selected : false; 68 | const rangeClasses = { 69 | "range-item": true, 70 | "root-range": level === 0, 71 | "sub-range": level > 0, 72 | selectable: selectable, 73 | "selected-range": selected, 74 | "non-selected-range": selectable && !selected, 75 | }; 76 | const rangeContent = html`
85 | ${key} 86 | ${groupRange.name} 87 |
`; 88 | 89 | if (hasSubRange) { 90 | const groupClasses = { 91 | "root-group": level === 0, 92 | "sub-group": level !== 0, 93 | }; 94 | return html`
95 | ${rangeContent} ${this._recurseData(groupRange.group_ranges, level + 1)} 96 |
`; 97 | } 98 | 99 | return html`${rangeContent}`; 100 | }); 101 | return html`${childTemplates}`; 102 | } 103 | 104 | private _selectionChangedMulti(ev) { 105 | const rangeKey = (ev.target as Element).getAttribute("toggle-range")!; 106 | this._selectableRanges[rangeKey].selected = !this._selectableRanges[rangeKey].selected; 107 | this._selectionUpdate(); 108 | this.requestUpdate(); 109 | } 110 | 111 | private _selectionChangedSingle(ev) { 112 | const rangeKey = (ev.target as Element).getAttribute("toggle-range")!; 113 | const rangePreviouslySelected = this._selectableRanges[rangeKey].selected; 114 | Object.values(this._selectableRanges).forEach((rangeInfo) => { 115 | rangeInfo.selected = false; 116 | }); 117 | this._selectableRanges[rangeKey].selected = !rangePreviouslySelected; 118 | this._selectionUpdate(); 119 | this.requestUpdate(); 120 | } 121 | 122 | private _selectionUpdate() { 123 | const _gaOfSelectedRanges = Object.values(this._selectableRanges).reduce( 124 | (result, rangeInfo) => 125 | rangeInfo.selected ? result.concat(rangeInfo.groupAddresses) : result, 126 | [] as string[], 127 | ); 128 | logger.debug("selection changed", _gaOfSelectedRanges); 129 | fireEvent(this, "knx-group-range-selection-changed", { groupAddresses: _gaOfSelectedRanges }); 130 | } 131 | 132 | static styles = css` 133 | :host { 134 | margin: 0; 135 | height: 100%; 136 | overflow-y: scroll; 137 | overflow-x: hidden; 138 | background-color: var(--card-background-color); 139 | } 140 | 141 | .ha-tree-view { 142 | cursor: default; 143 | } 144 | 145 | .root-group { 146 | margin-bottom: 8px; 147 | } 148 | 149 | .root-group > * { 150 | padding-top: 5px; 151 | padding-bottom: 5px; 152 | } 153 | 154 | .range-item { 155 | display: block; 156 | overflow: hidden; 157 | white-space: nowrap; 158 | text-overflow: ellipsis; 159 | font-size: 0.875rem; 160 | } 161 | 162 | .range-item > * { 163 | vertical-align: middle; 164 | pointer-events: none; 165 | } 166 | 167 | .range-key { 168 | color: var(--text-primary-color); 169 | font-size: 0.75rem; 170 | font-weight: 700; 171 | background-color: var(--label-badge-grey); 172 | border-radius: 4px; 173 | padding: 1px 4px; 174 | margin-right: 2px; 175 | } 176 | 177 | .root-range { 178 | padding-left: 8px; 179 | font-weight: 500; 180 | background-color: var(--secondary-background-color); 181 | 182 | & .range-key { 183 | color: var(--primary-text-color); 184 | background-color: var(--card-background-color); 185 | } 186 | } 187 | 188 | .sub-range { 189 | padding-left: 13px; 190 | } 191 | 192 | .selectable { 193 | cursor: pointer; 194 | } 195 | 196 | .selectable:hover { 197 | background-color: rgba(var(--rgb-primary-text-color), 0.04); 198 | } 199 | 200 | .selected-range { 201 | background-color: rgba(var(--rgb-primary-color), 0.12); 202 | 203 | & .range-key { 204 | background-color: var(--primary-color); 205 | } 206 | } 207 | 208 | .selected-range:hover { 209 | background-color: rgba(var(--rgb-primary-color), 0.07); 210 | } 211 | 212 | .non-selected-range { 213 | background-color: var(--card-background-color); 214 | } 215 | `; 216 | } 217 | 218 | declare global { 219 | interface HTMLElementTagNameMap { 220 | "knx-project-tree-view": KNXProjectTreeView; 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /src/components/knx-selector-row.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css, nothing } from "lit"; 2 | import type { TemplateResult } from "lit"; 3 | import { customElement, property, state } from "lit/decorators"; 4 | import { classMap } from "lit/directives/class-map"; 5 | 6 | import { fireEvent } from "@ha/common/dom/fire_event"; 7 | import "@ha/components/ha-checkbox"; 8 | import "@ha/components/ha-selector/ha-selector"; 9 | import "@ha/components/ha-switch"; 10 | import type { HomeAssistant } from "@ha/types"; 11 | import type { KnxHaSelector } from "../utils/schema"; 12 | 13 | @customElement("knx-selector-row") 14 | export class KnxSelectorRow extends LitElement { 15 | @property({ attribute: false }) public hass!: HomeAssistant; 16 | 17 | @property() public key!: string; 18 | 19 | @property({ attribute: false }) public selector!: KnxHaSelector; 20 | 21 | @property() public value?: any; 22 | 23 | @state() private _disabled = false; 24 | 25 | private _haSelectorValue: any = null; 26 | 27 | private _inlineSelector = false; 28 | 29 | private _optionalBooleanSelector = false; 30 | 31 | public connectedCallback() { 32 | super.connectedCallback(); 33 | this._disabled = !!this.selector.optional && this.value === undefined; 34 | // apply default value if available or no value is set yet 35 | this._haSelectorValue = this.value ?? this.selector.default ?? null; 36 | 37 | const booleanSelector = "boolean" in this.selector.selector; 38 | const possibleInlineSelector = booleanSelector || "number" in this.selector.selector; 39 | this._inlineSelector = !this.selector.optional && possibleInlineSelector; 40 | // optional boolean should not show as 2 switches (one for optional and one for value) 41 | this._optionalBooleanSelector = !!this.selector.optional && booleanSelector; 42 | if (this._optionalBooleanSelector) { 43 | // either true or the key will be unset (via this._disabled) 44 | this._haSelectorValue = true; 45 | } 46 | } 47 | 48 | protected render(): TemplateResult { 49 | const haSelector = this._optionalBooleanSelector 50 | ? nothing 51 | : html``; 59 | 60 | return html` 61 |
62 |
63 |

${this.selector.label}

64 |

${this.selector.helper}

65 |
66 | ${this.selector.optional 67 | ? html`` 73 | : this._inlineSelector 74 | ? haSelector 75 | : nothing} 76 |
77 | ${this._inlineSelector ? nothing : haSelector} 78 | `; 79 | } 80 | 81 | private _toggleDisabled(ev: Event) { 82 | ev.stopPropagation(); 83 | this._disabled = !this._disabled; 84 | this._propagateValue(); 85 | } 86 | 87 | private _valueChange(ev: Event) { 88 | ev.stopPropagation(); 89 | this._haSelectorValue = ev.detail.value; 90 | this._propagateValue(); 91 | } 92 | 93 | private _propagateValue() { 94 | fireEvent(this, "value-changed", { value: this._disabled ? undefined : this._haSelectorValue }); 95 | } 96 | 97 | static styles = css` 98 | :host { 99 | display: block; 100 | padding: 8px 16px 8px 0; 101 | border-top: 1px solid var(--divider-color); 102 | } 103 | .newline-selector { 104 | display: block; 105 | padding-top: 8px; 106 | } 107 | .body { 108 | display: flex; 109 | flex-wrap: wrap; 110 | align-items: center; 111 | row-gap: 8px; 112 | } 113 | .body > * { 114 | flex-grow: 1; 115 | } 116 | .text { 117 | flex-basis: 260px; /* min size of text - if inline selector is too big it will be pushed to next row */ 118 | } 119 | .heading { 120 | margin: 0; 121 | } 122 | .description { 123 | margin: 0; 124 | display: block; 125 | padding-top: 4px; 126 | font-family: var( 127 | --mdc-typography-body2-font-family, 128 | var(--mdc-typography-font-family, Roboto, sans-serif) 129 | ); 130 | -webkit-font-smoothing: antialiased; 131 | font-size: var(--mdc-typography-body2-font-size, 0.875rem); 132 | font-weight: var(--mdc-typography-body2-font-weight, 400); 133 | line-height: normal; 134 | color: var(--secondary-text-color); 135 | } 136 | `; 137 | } 138 | 139 | declare global { 140 | interface HTMLElementTagNameMap { 141 | "knx-selector-row": KnxSelectorRow; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/components/knx-sync-state-selector-row.ts: -------------------------------------------------------------------------------- 1 | import type { TemplateResult } from "lit"; 2 | import { css, html, LitElement } from "lit"; 3 | import { customElement, property } from "lit/decorators"; 4 | 5 | import { fireEvent } from "@ha/common/dom/fire_event"; 6 | import "@ha/components/ha-selector/ha-selector-number"; 7 | import "@ha/components/ha-selector/ha-selector-select"; 8 | import type { HomeAssistant } from "@ha/types"; 9 | 10 | @customElement("knx-sync-state-selector-row") 11 | export class KnxSyncStateSelectorRow extends LitElement { 12 | @property({ attribute: false }) public hass!: HomeAssistant; 13 | 14 | @property() public value: string | boolean = true; 15 | 16 | @property() public key = "sync_state"; 17 | 18 | @property({ attribute: false }) noneValid = true; 19 | 20 | private _strategy: boolean | "init" | "expire" | "every" = true; 21 | 22 | private _minutes = 60; 23 | 24 | protected _hasMinutes(strategy: boolean | string): boolean { 25 | return strategy === "expire" || strategy === "every"; 26 | } 27 | 28 | protected willUpdate() { 29 | if (typeof this.value === "boolean") { 30 | this._strategy = this.value; 31 | return; 32 | } 33 | const [strategy, minutes] = this.value.split(" "); 34 | this._strategy = strategy; 35 | if (+minutes) { 36 | this._minutes = +minutes; 37 | } 38 | } 39 | 40 | protected render(): TemplateResult { 41 | return html`

42 | Actively request state updates from KNX bus for state addresses. 43 |

44 |
45 | 66 | 67 | 82 | 83 |
`; 84 | } 85 | 86 | private _handleChange(ev) { 87 | ev.stopPropagation(); 88 | let strategy: boolean | string; 89 | let minutes: number; 90 | if (ev.target.key === "strategy") { 91 | strategy = ev.detail.value; 92 | minutes = this._minutes; 93 | } else { 94 | strategy = this._strategy; 95 | minutes = ev.detail.value; 96 | } 97 | const value = this._hasMinutes(strategy) ? `${strategy} ${minutes}` : strategy; 98 | fireEvent(this, "value-changed", { value }); 99 | } 100 | 101 | static styles = css` 102 | .description { 103 | margin: 0; 104 | display: block; 105 | padding-top: 4px; 106 | padding-bottom: 8px; 107 | font-family: var( 108 | --mdc-typography-body2-font-family, 109 | var(--mdc-typography-font-family, Roboto, sans-serif) 110 | ); 111 | -webkit-font-smoothing: antialiased; 112 | font-size: var(--mdc-typography-body2-font-size, 0.875rem); 113 | font-weight: var(--mdc-typography-body2-font-weight, 400); 114 | line-height: normal; 115 | color: var(--secondary-text-color); 116 | } 117 | .inline { 118 | width: 100%; 119 | display: inline-flex; 120 | flex-flow: row wrap; 121 | gap: 16px; 122 | justify-content: space-between; 123 | } 124 | .inline > * { 125 | flex: 1; 126 | width: 100%; /* to not overflow when wrapped */ 127 | } 128 | `; 129 | } 130 | 131 | declare global { 132 | interface HTMLElementTagNameMap { 133 | "knx-sync-state-selector-row": KnxSyncStateSelectorRow; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/constants.py: -------------------------------------------------------------------------------- 1 | """ 2 | Constants for KNX panel. 3 | 4 | For builds, this file is generated entirely by in build-scripts/gulp/entry-html.js 5 | changes here aren't reflected. 6 | """ 7 | FILE_HASH = "" 8 | -------------------------------------------------------------------------------- /src/dialogs/knx-device-create-dialog.ts: -------------------------------------------------------------------------------- 1 | import "@material/mwc-button/mwc-button"; 2 | import { LitElement, html, css } from "lit"; 3 | import { customElement, property, state } from "lit/decorators"; 4 | 5 | import { navigate } from "@ha/common/navigate"; 6 | import "@ha/components/ha-area-picker"; 7 | import "@ha/components/ha-dialog"; 8 | import "@ha/components/ha-selector/ha-selector-text"; 9 | 10 | import { fireEvent } from "@ha/common/dom/fire_event"; 11 | import { haStyleDialog } from "@ha/resources/styles"; 12 | import type { DeviceRegistryEntry } from "@ha/data/device_registry"; 13 | import type { HomeAssistant } from "@ha/types"; 14 | 15 | import { createDevice } from "../services/websocket.service"; 16 | import { KNXLogger } from "../tools/knx-logger"; 17 | 18 | const logger = new KNXLogger("create_device_dialog"); 19 | 20 | declare global { 21 | // for fire event 22 | interface HASSDomEvents { 23 | "create-device-dialog-closed": { newDevice: DeviceRegistryEntry | undefined }; 24 | } 25 | } 26 | 27 | @customElement("knx-device-create-dialog") 28 | class DeviceCreateDialog extends LitElement { 29 | @property({ attribute: false }) public hass!: HomeAssistant; 30 | 31 | @property({ attribute: false }) public deviceName?: string; 32 | 33 | @state() private area?: string; 34 | 35 | _deviceEntry?: DeviceRegistryEntry; 36 | 37 | public closeDialog(_ev) { 38 | fireEvent( 39 | this, 40 | "create-device-dialog-closed", 41 | { newDevice: this._deviceEntry }, 42 | { bubbles: false }, 43 | ); 44 | } 45 | 46 | private _createDevice() { 47 | createDevice(this.hass, { name: this.deviceName!, area_id: this.area }) 48 | .then((resultDevice) => { 49 | this._deviceEntry = resultDevice; 50 | }) 51 | .catch((err) => { 52 | logger.error("getGroupMonitorInfo", err); 53 | navigate("/knx/error", { replace: true, data: err }); 54 | }) 55 | .finally(() => { 56 | this.closeDialog(undefined); 57 | }); 58 | } 59 | 60 | protected render() { 61 | return html` 68 | 79 | 86 | 87 | 88 | ${this.hass.localize("ui.common.cancel")} 89 | 90 | 91 | ${this.hass.localize("ui.common.add")} 92 | 93 | `; 94 | } 95 | 96 | protected _valueChanged(ev: CustomEvent) { 97 | ev.stopPropagation(); 98 | this[ev.target.key] = ev.detail.value; 99 | } 100 | 101 | static get styles() { 102 | return [ 103 | haStyleDialog, 104 | css` 105 | @media all and (min-width: 600px) { 106 | ha-dialog { 107 | --mdc-dialog-min-width: 480px; 108 | } 109 | } 110 | `, 111 | ]; 112 | } 113 | } 114 | 115 | declare global { 116 | interface HTMLElementTagNameMap { 117 | "knx-device-create-dialog": DeviceCreateDialog; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/dialogs/knx-telegram-info-dialog.ts: -------------------------------------------------------------------------------- 1 | import "@material/mwc-button/mwc-button"; 2 | import { LitElement, nothing, html, css } from "lit"; 3 | import { customElement, property } from "lit/decorators"; 4 | 5 | import { fireEvent } from "@ha/common/dom/fire_event"; 6 | import { haStyleDialog } from "@ha/resources/styles"; 7 | import type { HomeAssistant } from "@ha/types"; 8 | import { createCloseHeading } from "@ha/components/ha-dialog"; 9 | 10 | import type { KNX } from "../types/knx"; 11 | import type { TelegramDict } from "../types/websocket"; 12 | import { TelegramDictFormatter } from "../utils/format"; 13 | 14 | declare global { 15 | // for fire event 16 | interface HASSDomEvents { 17 | "next-telegram": undefined; 18 | "previous-telegram": undefined; 19 | "dialog-close": undefined; 20 | } 21 | } 22 | 23 | @customElement("knx-telegram-info-dialog") 24 | class TelegramInfoDialog extends LitElement { 25 | public hass!: HomeAssistant; 26 | 27 | @property({ attribute: false }) public knx!: KNX; 28 | 29 | @property({ attribute: false }) public index?: number; 30 | 31 | @property({ attribute: false }) public telegram?: TelegramDict; 32 | 33 | @property({ attribute: false }) public disableNext = false; 34 | 35 | @property({ attribute: false }) public disablePrevious = false; 36 | 37 | public closeDialog() { 38 | this.telegram = undefined; 39 | this.index = undefined; 40 | fireEvent(this, "dialog-closed", { dialog: this.localName }, { bubbles: false }); 41 | } 42 | 43 | protected render() { 44 | if (this.telegram == null) { 45 | this.closeDialog(); 46 | return nothing; 47 | } 48 | return html` 56 |
57 |
58 |
${TelegramDictFormatter.dateWithMilliseconds(this.telegram)}
59 |
${this.knx.localize(this.telegram.direction)}
60 |
61 |
62 |

${this.knx.localize("group_monitor_source")}

63 |
64 |
${this.telegram.source}
65 |
${this.telegram.source_name}
66 |
67 |
68 |
69 |

${this.knx.localize("group_monitor_destination")}

70 |
71 |
${this.telegram.destination}
72 |
${this.telegram.destination_name}
73 |
74 |
75 |
76 |

${this.knx.localize("group_monitor_message")}

77 |
78 |
${this.telegram.telegramtype}
79 |
${TelegramDictFormatter.dptNameNumber(this.telegram)}
80 |
81 | ${this.telegram.payload != null 82 | ? html`
83 |
${this.knx.localize("group_monitor_payload")}
84 |
${TelegramDictFormatter.payload(this.telegram)}
85 |
` 86 | : nothing} 87 | ${this.telegram.value != null 88 | ? html`
89 |
${this.knx.localize("group_monitor_value")}
90 |
${TelegramDictFormatter.valueWithUnit(this.telegram)}
91 |
` 92 | : nothing} 93 |
94 |
95 | 100 | ${this.hass.localize("ui.common.previous")} 101 | 102 | 103 | ${this.hass.localize("ui.common.next")} 104 | 105 |
`; 106 | } 107 | 108 | private _nextTelegram() { 109 | fireEvent(this, "next-telegram"); 110 | } 111 | 112 | private _previousTelegram() { 113 | fireEvent(this, "previous-telegram"); 114 | } 115 | 116 | static get styles() { 117 | return [ 118 | haStyleDialog, 119 | css` 120 | ha-dialog { 121 | --vertical-align-dialog: center; 122 | --dialog-z-index: 20; 123 | } 124 | @media all and (max-width: 450px), all and (max-height: 500px) { 125 | /* When in fullscreen dialog should be attached to top */ 126 | ha-dialog { 127 | --dialog-surface-margin-top: 0px; 128 | } 129 | } 130 | @media all and (min-width: 600px) and (min-height: 501px) { 131 | /* Set the dialog to a fixed size, so it doesnt jump when the content changes size */ 132 | ha-dialog { 133 | --mdc-dialog-min-width: 580px; 134 | --mdc-dialog-max-width: 580px; 135 | --mdc-dialog-min-height: 70%; 136 | --mdc-dialog-max-height: 70%; 137 | } 138 | } 139 | 140 | .content { 141 | display: flex; 142 | flex-direction: column; 143 | outline: none; 144 | flex: 1; 145 | } 146 | 147 | h4 { 148 | margin-top: 24px; 149 | margin-bottom: 12px; 150 | border-bottom: 1px solid var(--divider-color); 151 | color: var(--secondary-text-color); 152 | } 153 | 154 | .section > div { 155 | margin-bottom: 12px; 156 | } 157 | .row { 158 | display: flex; 159 | flex-direction: row; 160 | justify-content: space-between; 161 | flex-wrap: wrap; 162 | } 163 | 164 | .row-inline { 165 | display: flex; 166 | flex-direction: row; 167 | gap: 10px; 168 | } 169 | 170 | pre { 171 | margin-top: 0; 172 | margin-bottom: 0; 173 | } 174 | 175 | mwc-button { 176 | user-select: none; 177 | -webkit-user-select: none; 178 | -moz-user-select: none; 179 | -ms-user-select: none; 180 | } 181 | `, 182 | ]; 183 | } 184 | } 185 | 186 | declare global { 187 | interface HTMLElementTagNameMap { 188 | "knx-telegram-info-dialog": TelegramInfoDialog; 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/entrypoint.ts: -------------------------------------------------------------------------------- 1 | import "@ha/resources/roboto"; 2 | import "./main"; 3 | 4 | const styleEl = document.createElement("style"); 5 | styleEl.innerHTML = ` 6 | body { 7 | font-family: Roboto, sans-serif; 8 | -moz-osx-font-smoothing: grayscale; 9 | -webkit-font-smoothing: antialiased; 10 | font-weight: 400; 11 | margin: 0; 12 | padding: 0; 13 | height: 100vh; 14 | } 15 | @media (prefers-color-scheme: dark) { 16 | body { 17 | background-color: #111111; 18 | color: #e1e1e1; 19 | } 20 | } 21 | `; 22 | 23 | document.head.appendChild(styleEl); 24 | -------------------------------------------------------------------------------- /src/knx-router.ts: -------------------------------------------------------------------------------- 1 | import { mdiNetwork, mdiFolderMultipleOutline, mdiFileTreeOutline } from "@mdi/js"; 2 | import { customElement, property } from "lit/decorators"; 3 | 4 | import type { RouterOptions } from "@ha/layouts/hass-router-page"; 5 | import { HassRouterPage } from "@ha/layouts/hass-router-page"; 6 | import type { PageNavigation } from "@ha/layouts/hass-tabs-subpage"; 7 | import type { HomeAssistant, Route } from "@ha/types"; 8 | 9 | import { mainWindow } from "@ha/common/dom/get_main_window"; 10 | import type { KNX } from "./types/knx"; 11 | import { KNXLogger } from "./tools/knx-logger"; 12 | 13 | const logger = new KNXLogger("router"); 14 | 15 | export const BASE_URL = "/knx"; 16 | 17 | const knxMainTabs = (hasProject: boolean): PageNavigation[] => [ 18 | { 19 | translationKey: "info_title", 20 | path: `${BASE_URL}/info`, 21 | iconPath: mdiFolderMultipleOutline, 22 | }, 23 | { 24 | translationKey: "group_monitor_title", 25 | path: `${BASE_URL}/group_monitor`, 26 | iconPath: mdiNetwork, 27 | }, 28 | ...(hasProject 29 | ? [ 30 | { 31 | translationKey: "project_title", 32 | path: `${BASE_URL}/project`, 33 | iconPath: mdiFileTreeOutline, 34 | }, 35 | ] 36 | : []), 37 | { 38 | translationKey: "entities_view_title", 39 | path: `${BASE_URL}/entities`, 40 | iconPath: mdiFileTreeOutline, 41 | }, 42 | ]; 43 | 44 | @customElement("knx-router") 45 | export class KnxRouter extends HassRouterPage { 46 | @property({ attribute: false }) public hass!: HomeAssistant; 47 | 48 | @property({ attribute: false }) public knx!: KNX; 49 | 50 | @property({ attribute: false }) public route!: Route; 51 | 52 | @property({ type: Boolean }) public narrow!: boolean; 53 | 54 | protected routerOptions: RouterOptions = { 55 | defaultPage: "info", 56 | beforeRender: (page: string) => (page === "" ? this.routerOptions.defaultPage : undefined), 57 | routes: { 58 | info: { 59 | tag: "knx-info", 60 | load: () => { 61 | logger.debug("Importing knx-info"); 62 | return import("./views/info"); 63 | }, 64 | }, 65 | group_monitor: { 66 | tag: "knx-group-monitor", 67 | load: () => { 68 | logger.debug("Importing knx-group-monitor"); 69 | return import("./views/group_monitor"); 70 | }, 71 | }, 72 | project: { 73 | tag: "knx-project-view", 74 | load: () => { 75 | logger.debug("Importing knx-project-view"); 76 | return import("./views/project_view"); 77 | }, 78 | }, 79 | entities: { 80 | tag: "knx-entities-router", 81 | load: () => { 82 | logger.debug("Importing knx-entities-view"); 83 | return import("./views/entities_router"); 84 | }, 85 | }, 86 | error: { 87 | tag: "knx-error", 88 | load: () => { 89 | logger.debug("Importing knx-error"); 90 | return import("./views/error"); 91 | }, 92 | }, 93 | }, 94 | }; 95 | 96 | protected updatePageEl(el, changedProps) { 97 | // skip title setting when sub-router is called - it will set the title itself when calling this method 98 | // changedProps is undefined when the element was just loaded 99 | if (!(el instanceof KnxRouter) && changedProps === undefined) { 100 | // look for translation of "prefix_currentPage_title" and set it as title 101 | let pathSlug: string[] = []; 102 | if (this.route.prefix.startsWith("/knx/")) { 103 | pathSlug = this.route.prefix.substring(5).split("/"); 104 | } 105 | pathSlug.push(this._currentPage, "title"); 106 | const title_translation_key = pathSlug.join("_"); 107 | const title = this.knx.localize(title_translation_key); 108 | mainWindow.document.title = 109 | title === title_translation_key 110 | ? "KNX - Home Assistant" 111 | : `${title} - KNX - Home Assistant`; 112 | } 113 | 114 | el.hass = this.hass; 115 | el.knx = this.knx; 116 | el.route = this.routeTail; 117 | el.narrow = this.narrow; 118 | el.tabs = knxMainTabs(!!this.knx.info.project); 119 | } 120 | } 121 | 122 | declare global { 123 | interface HTMLElementTagNameMap { 124 | "knx-router": KnxRouter; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/knx.ts: -------------------------------------------------------------------------------- 1 | import { LitElement } from "lit"; 2 | import { property } from "lit/decorators"; 3 | 4 | import { navigate } from "@ha/common/navigate"; 5 | import { getConfigEntries } from "@ha/data/config_entries"; 6 | import { ProvideHassLitMixin } from "@ha/mixins/provide-hass-lit-mixin"; 7 | import type { HomeAssistant } from "@ha/types"; 8 | 9 | import { localize } from "./localize/localize"; 10 | import { KNXLogger } from "./tools/knx-logger"; 11 | import { getKnxInfoData, getKnxProject } from "./services/websocket.service"; 12 | import type { KNX } from "./types/knx"; 13 | 14 | export class KnxElement extends ProvideHassLitMixin(LitElement) { 15 | @property({ attribute: false }) public hass!: HomeAssistant; 16 | 17 | @property({ attribute: false }) public knx!: KNX; 18 | 19 | protected async _initKnx() { 20 | try { 21 | const knxConfigEntries = await getConfigEntries(this.hass, { domain: "knx" }); 22 | const knxInfo = await getKnxInfoData(this.hass); 23 | this.knx = { 24 | language: this.hass.language, 25 | config_entry: knxConfigEntries[0], // single instance allowed for knx config 26 | localize: (string, replace) => localize(this.hass, string, replace), 27 | log: new KNXLogger(), 28 | info: knxInfo, 29 | project: null, 30 | loadProject: () => this._loadProjectPromise(), 31 | }; 32 | } catch (err) { 33 | new KNXLogger().error("Failed to initialize KNX", err); 34 | } 35 | } 36 | 37 | private _loadProjectPromise(): Promise { 38 | // load project only when needed since it can be quite big 39 | // check this.knx.project if it is available in using component 40 | return getKnxProject(this.hass) 41 | .then((knxProjectResp) => { 42 | this.knx.project = knxProjectResp; 43 | }) 44 | .catch((err) => { 45 | this.knx.log.error("getKnxProject", err); 46 | navigate("/knx/error", { replace: true, data: err }); 47 | }); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/localize/languages/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "KNX", 3 | "attention": "Achtung", 4 | "info_title": "Info", 5 | "info_connected_to_bus": "Verbunden", 6 | "info_individual_address": "Physikalische Adresse", 7 | "info_information_header": "Information", 8 | "info_project_data_header": "Projektdaten", 9 | "info_project_data_name": "Projektname", 10 | "info_project_data_last_modified": "Zuletzt geändert", 11 | "info_project_data_tool_version": "Tool-Version", 12 | "info_project_data_xknxproject_version": "XKNXProject Version", 13 | "info_project_file_header": "ETS Projektdatei", 14 | "info_project_file": "ETS Projektdatei", 15 | "info_project_delete": "Projektdaten löschen", 16 | "info_project_upload_description": "Wir extrahieren Details wie Namen, Gruppenadressen, Geräte, Gruppenobjekte, Topologie und Gebäudestruktur aus deiner Projektdatei. Home Assistant speichert jedoch aus Sicherheitsgründen weder die Projektdatei selbst, noch das optionale Passwort.", 17 | "info_issue_tracker": "Wenn du einen Fehler melden oder eine neue Funktion vorschlagen möchtest, erstelle ein Issue in unserem GitHub-Repository", 18 | "info_my_knx": "Wenn du mehr über das KNX System oder ETS erfahren möchtest, besuche", 19 | "entities_view_title": "Entitäten", 20 | "entities_create_title": "Entität erstellen", 21 | "entities_edit_title": "Entität bearbeiten", 22 | "group_monitor_title": "Gruppenmonitor", 23 | "group_monitor_time": "Zeit", 24 | "group_monitor_direction": "Richtung", 25 | "group_monitor_source": "Quelle", 26 | "group_monitor_destination": "Ziel", 27 | "group_monitor_message": "Nachricht", 28 | "group_monitor_telegram": "Telegramm", 29 | "group_monitor_type": "Typ", 30 | "group_monitor_payload": "Daten", 31 | "group_monitor_connected_waiting_telegrams": "Gruppenmonitor ist verbunden und wartet auf Telegramme", 32 | "group_monitor_value": "Wert", 33 | "group_monitor_waiting_to_connect": "Warte auf Websocket-Verbindung", 34 | "project_title": "Projekt", 35 | "project_view_upload": "Keine Projektdaten gefunden. Wechsle zum Info Tab und lade eine Projektdatei hoch.", 36 | "project_view_version_l1": "Die Version von `XKNXProject` welche für die Verarbeitung des ETS Projektes verwendet wurde, war:", 37 | "project_view_version_l2": "minimal unterstützte Version", 38 | "project_view_version_l3": "Lade die Projektdatei erneut hoch, um das ETS Projekt Werkzeug zu nutzen", 39 | "project_view_table_address": "Adresse", 40 | "project_view_table_last_value": "Letzter Wert", 41 | "project_view_table_name": "Name", 42 | "project_view_table_description": "Beschreibung", 43 | "project_view_table_dpt": "DPT", 44 | "project_view_table_updated": "Aktualisiert", 45 | "Incoming": "Eingehend", 46 | "Outgoing": "Ausgehend" 47 | } 48 | -------------------------------------------------------------------------------- /src/localize/languages/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "KNX", 3 | "attention": "Attention", 4 | "info_title": "Info", 5 | "info_connected_to_bus": "Connected to bus", 6 | "info_individual_address": "Individual address", 7 | "info_information_header": "Information", 8 | "info_project_data_header": "ETS Project data", 9 | "info_project_data_name": "Project name", 10 | "info_project_data_last_modified": "Last modified", 11 | "info_project_data_tool_version": "Tool version", 12 | "info_project_data_xknxproject_version": "XKNXProject version", 13 | "info_project_file_header": "ETS Project file", 14 | "info_project_file": "ETS Project file", 15 | "info_project_delete": "Delete project data", 16 | "info_project_upload_description": "We extract details such as names, group addresses, devices, group objects, topology, and building structure from your project file. Home Assistant does not store the ETS project file itself nor its optional password for security reasons.", 17 | "info_issue_tracker": "If you'd like to report a bug or suggest a new feature, create an issue in our GitHub repository", 18 | "info_my_knx": "If you'd like to learn more about the KNX system or ETS, visit", 19 | "entities_view_title": "Entities", 20 | "entities_create_title": "Create entity", 21 | "entities_edit_title": "Edit entity", 22 | "group_monitor_title": "Group Monitor", 23 | "group_monitor_time": "Time", 24 | "group_monitor_direction": "Direction", 25 | "group_monitor_source": "Source", 26 | "group_monitor_destination": "Destination", 27 | "group_monitor_message": "Message", 28 | "group_monitor_telegram": "Telegram", 29 | "group_monitor_type": "Type", 30 | "group_monitor_payload": "Payload", 31 | "group_monitor_connected_waiting_telegrams": "Group monitor is connected and waiting for telegrams", 32 | "group_monitor_value": "Value", 33 | "group_monitor_waiting_to_connect": "Waiting for websocket connection", 34 | "project_title": "Project", 35 | "project_view_upload": "To use the project viewr please upload a KNX project on Info tab first!", 36 | "project_view_version_l1": "The `XKNXProject` version used for parsing the ETS project was:", 37 | "project_view_version_l2": "minimum version required", 38 | "project_view_version_l3": "Please resubmit your ETS project file.", 39 | "project_view_table_address": "Address", 40 | "project_view_table_last_value": "Last value", 41 | "project_view_table_name": "Name", 42 | "project_view_table_description": "Description", 43 | "project_view_table_dpt": "DPT", 44 | "project_view_table_updated": "Updated", 45 | "project_view_add_switch": "Add switch", 46 | "Incoming": "Incoming", 47 | "Outgoing": "Outgoing" 48 | } 49 | -------------------------------------------------------------------------------- /src/localize/localize.ts: -------------------------------------------------------------------------------- 1 | import IntlMessageFormat from "intl-messageformat"; 2 | import type { HomeAssistant } from "@ha/types"; 3 | import * as de from "./languages/de.json"; 4 | import * as en from "./languages/en.json"; 5 | 6 | import { KNXLogger } from "../tools/knx-logger"; 7 | 8 | const languages = { 9 | de, 10 | en, 11 | }; 12 | const DEFAULT_LANGUAGE = "en"; 13 | const logger = new KNXLogger("localize"); 14 | const warnings: { language: string[]; sting: Record } = { 15 | language: [], 16 | sting: {}, 17 | }; 18 | 19 | const _localizationCache = {}; 20 | 21 | export function localize(hass: HomeAssistant, key: string, replace?: Record): string { 22 | let lang = (hass.language || localStorage.getItem("selectedLanguage") || DEFAULT_LANGUAGE) 23 | .replace(/['"]+/g, "") 24 | .replace("-", "_"); 25 | 26 | if (!languages[lang]) { 27 | if (!warnings.language?.includes(lang)) { 28 | warnings.language.push(lang); 29 | } 30 | lang = DEFAULT_LANGUAGE; 31 | } 32 | 33 | const translatedValue = languages[lang]?.[key] || languages[DEFAULT_LANGUAGE][key]; 34 | 35 | if (!translatedValue) { 36 | const hassTranslation = hass.localize(key, replace); 37 | if (hassTranslation) { 38 | return hassTranslation; 39 | } 40 | logger.error(`Translation problem with '${key}' for '${lang}'`); 41 | return key; 42 | } 43 | 44 | const messageKey = key + translatedValue; 45 | 46 | let translatedMessage = _localizationCache[messageKey] as IntlMessageFormat | undefined; 47 | 48 | if (!translatedMessage) { 49 | try { 50 | translatedMessage = new IntlMessageFormat(translatedValue, lang); 51 | } catch (_err: any) { 52 | logger.warn(`Translation problem with '${key}' for '${lang}'`); 53 | return key; 54 | } 55 | _localizationCache[messageKey] = translatedMessage; 56 | } 57 | 58 | try { 59 | return translatedMessage.format(replace) as string; 60 | } catch (_err: any) { 61 | logger.warn(`Translation problem with '${key}' for '${lang}'`); 62 | return key; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import type { LitElement } from "lit"; 2 | import { css, html } from "lit"; 3 | import { customElement, property } from "lit/decorators"; 4 | 5 | import { applyThemesOnElement } from "@ha/common/dom/apply_themes_on_element"; 6 | import { fireEvent } from "@ha/common/dom/fire_event"; 7 | import { mainWindow } from "@ha/common/dom/get_main_window"; 8 | import { listenMediaQuery } from "@ha/common/dom/media_query"; 9 | import { computeRTL, computeDirectionStyles } from "@ha/common/util/compute_rtl"; 10 | import { navigate } from "@ha/common/navigate"; 11 | import { makeDialogManager } from "@ha/dialogs/make-dialog-manager"; 12 | import "@ha/resources/append-ha-style"; 13 | import type { HomeAssistant, Route } from "@ha/types"; 14 | 15 | import { KnxElement } from "./knx"; 16 | import "./knx-router"; 17 | import type { KNX } from "./types/knx"; 18 | import type { LocationChangedEvent } from "./types/navigation"; 19 | 20 | declare global { 21 | // for fire event 22 | interface HASSDomEvents { 23 | "knx-reload": undefined; 24 | } 25 | } 26 | 27 | @customElement("knx-frontend") 28 | class KnxFrontend extends KnxElement { 29 | @property({ attribute: false }) public hass!: HomeAssistant; 30 | 31 | @property({ attribute: false }) public knx!: KNX; 32 | 33 | @property({ attribute: false }) public narrow!: boolean; 34 | 35 | @property({ attribute: false }) public route!: Route; 36 | 37 | protected async firstUpdated(_changedProps) { 38 | if (!this.hass) { 39 | return; 40 | } 41 | if (!this.knx) { 42 | await this._initKnx(); 43 | } 44 | this.addEventListener("knx-location-changed", (e) => this._setRoute(e as LocationChangedEvent)); 45 | 46 | this.addEventListener("knx-reload", async (_) => { 47 | this.knx.log.debug("Reloading KNX object"); 48 | await this._initKnx(); 49 | }); 50 | 51 | computeDirectionStyles(computeRTL(this.hass), this.parentElement as LitElement); 52 | 53 | document.body.addEventListener("keydown", (ev: KeyboardEvent) => { 54 | if (ev.ctrlKey || ev.shiftKey || ev.metaKey || ev.altKey) { 55 | // Ignore if modifier keys are pressed 56 | return; 57 | } 58 | if (["a", "c", "d", "e", "m"].includes(ev.key)) { 59 | // @ts-ignore 60 | fireEvent(mainWindow, "hass-quick-bar-trigger", ev, { 61 | bubbles: false, 62 | }); 63 | } 64 | }); 65 | 66 | listenMediaQuery("(prefers-color-scheme: dark)", (_matches) => { 67 | this._applyTheme(); 68 | }); 69 | 70 | makeDialogManager(this, this.shadowRoot!); 71 | } 72 | 73 | protected render() { 74 | if (!this.hass || !this.knx) { 75 | return html`

Loading...

`; 76 | } 77 | 78 | return html` 79 | 85 | `; 86 | } 87 | 88 | static styles = 89 | // apply "Settings" style toolbar color for `hass-subpage` 90 | css` 91 | :host { 92 | --app-header-background-color: var(--sidebar-background-color); 93 | --app-header-text-color: var(--sidebar-text-color); 94 | --app-header-border-bottom: 1px solid var(--divider-color); 95 | --knx-green: #5e8a3a; 96 | --knx-blue: #2a4691; 97 | } 98 | `; 99 | 100 | private _setRoute(ev: LocationChangedEvent): void { 101 | if (!ev.detail?.route) { 102 | return; 103 | } 104 | this.route = ev.detail.route; 105 | navigate(this.route.path, { replace: true }); 106 | this.requestUpdate(); 107 | } 108 | 109 | private _applyTheme() { 110 | applyThemesOnElement( 111 | this.parentElement, 112 | this.hass.themes, 113 | this.hass.selectedTheme?.theme || 114 | (this.hass.themes.darkMode && this.hass.themes.default_dark_theme 115 | ? this.hass.themes.default_dark_theme! 116 | : this.hass.themes.default_theme), 117 | { 118 | ...this.hass.selectedTheme, 119 | dark: this.hass.themes.darkMode, 120 | }, 121 | ); 122 | this.parentElement!.style.backgroundColor = "var(--primary-background-color)"; 123 | this.parentElement!.style.color = "var(--primary-text-color)"; 124 | } 125 | } 126 | 127 | declare global { 128 | interface HTMLElementTagNameMap { 129 | "knx-frontend": KnxFrontend; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/py.typed: -------------------------------------------------------------------------------- 1 | Placeholder file for the empty `py.typed` file created by build-scripts/gulp/entry-html.js 2 | -------------------------------------------------------------------------------- /src/services/websocket.service.ts: -------------------------------------------------------------------------------- 1 | import type { HomeAssistant } from "@ha/types"; 2 | import type { ExtEntityRegistryEntry } from "@ha/data/entity_registry"; 3 | import type { DeviceRegistryEntry } from "@ha/data/device_registry"; 4 | import type { 5 | KNXInfoData, 6 | TelegramDict, 7 | GroupMonitorInfoData, 8 | KNXProjectResponse, 9 | } from "../types/websocket"; 10 | import type { 11 | CreateEntityData, 12 | CreateEntityResult, 13 | UpdateEntityData, 14 | DeviceCreateData, 15 | } from "../types/entity_data"; 16 | 17 | export const getKnxInfoData = (hass: HomeAssistant): Promise => 18 | hass.callWS({ 19 | type: "knx/info", 20 | }); 21 | 22 | export const processProjectFile = ( 23 | hass: HomeAssistant, 24 | file_id: string, 25 | password: string, 26 | ): Promise => 27 | hass.callWS({ 28 | type: "knx/project_file_process", 29 | file_id: file_id, 30 | password: password, 31 | }); 32 | 33 | export const removeProjectFile = (hass: HomeAssistant): Promise => 34 | hass.callWS({ 35 | type: "knx/project_file_remove", 36 | }); 37 | 38 | export const getGroupMonitorInfo = (hass: HomeAssistant): Promise => 39 | hass.callWS({ 40 | type: "knx/group_monitor_info", 41 | }); 42 | 43 | export const getGroupTelegrams = (hass: HomeAssistant): Promise> => 44 | hass.callWS({ 45 | type: "knx/group_telegrams", 46 | }); 47 | 48 | export const subscribeKnxTelegrams = ( 49 | hass: HomeAssistant, 50 | callback: (telegram: TelegramDict) => void, 51 | ) => 52 | hass.connection.subscribeMessage(callback, { 53 | type: "knx/subscribe_telegrams", 54 | }); 55 | 56 | export const getKnxProject = (hass: HomeAssistant): Promise => 57 | hass.callWS({ 58 | type: "knx/get_knx_project", 59 | }); 60 | 61 | /** 62 | * Entity store calls. 63 | */ 64 | export const validateEntity = ( 65 | hass: HomeAssistant, 66 | entityData: CreateEntityData | UpdateEntityData, 67 | ): Promise => // CreateEntityResult.entity_id will be null when only validating 68 | hass.callWS({ 69 | type: "knx/validate_entity", 70 | ...entityData, 71 | }); 72 | 73 | export const createEntity = ( 74 | hass: HomeAssistant, 75 | entityData: CreateEntityData, 76 | ): Promise => 77 | hass.callWS({ 78 | type: "knx/create_entity", 79 | ...entityData, 80 | }); 81 | 82 | export const updateEntity = ( 83 | hass: HomeAssistant, 84 | entityData: UpdateEntityData, 85 | ): Promise => // CreateEntityResult.entity_id will be null when updating 86 | hass.callWS({ 87 | type: "knx/update_entity", 88 | ...entityData, 89 | }); 90 | 91 | export const deleteEntity = (hass: HomeAssistant, entityId: string) => 92 | hass.callWS({ 93 | type: "knx/delete_entity", 94 | entity_id: entityId, 95 | }); 96 | 97 | export const getEntityConfig = (hass: HomeAssistant, entityId: string): Promise => 98 | hass.callWS({ 99 | type: "knx/get_entity_config", 100 | entity_id: entityId, 101 | }); 102 | 103 | export const getEntityEntries = (hass: HomeAssistant): Promise => 104 | hass.callWS({ 105 | type: "knx/get_entity_entries", 106 | }); 107 | 108 | export const createDevice = ( 109 | hass: HomeAssistant, 110 | deviceData: DeviceCreateData, 111 | ): Promise => 112 | hass.callWS({ 113 | type: "knx/create_device", 114 | ...deviceData, 115 | }); 116 | -------------------------------------------------------------------------------- /src/tools/knx-logger.ts: -------------------------------------------------------------------------------- 1 | export class KNXLogger { 2 | prefix: string; 3 | 4 | constructor(name?: string) { 5 | if (name) { 6 | this.prefix = `[knx.${name}]`; 7 | } else { 8 | this.prefix = `[knx]`; 9 | } 10 | } 11 | 12 | public info(...content: any[]): void { 13 | // eslint-disable-next-line no-console 14 | console.info(this.prefix, ...content); 15 | } 16 | 17 | public log(...content: any[]): void { 18 | // eslint-disable-next-line no-console 19 | console.log(this.prefix, ...content); 20 | } 21 | 22 | public debug(...content: any[]): void { 23 | // eslint-disable-next-line no-console 24 | console.debug(this.prefix, ...content); 25 | } 26 | 27 | public warn(...content: any[]): void { 28 | // eslint-disable-next-line no-console 29 | console.warn(this.prefix, ...content); 30 | } 31 | 32 | public error(...content: any[]): void { 33 | // eslint-disable-next-line no-console 34 | console.error(this.prefix, ...content); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/types/entity_data.ts: -------------------------------------------------------------------------------- 1 | export type EntityCategory = "config" | "diagnostic"; 2 | 3 | export type SupportedPlatform = "switch" | "light" | "binary_sensor" | "cover"; 4 | 5 | export interface GASchema { 6 | write?: string; 7 | state?: string; 8 | passive?: string[]; 9 | dpt?: string; 10 | } 11 | 12 | export interface BaseEntityData { 13 | device_info: string | null; 14 | entity_category: EntityCategory | null; 15 | name: string; 16 | } 17 | 18 | export interface SwitchEntityData { 19 | entity: BaseEntityData; 20 | invert: boolean; 21 | respond_to_read: boolean; 22 | ga_switch: GASchema; 23 | sync_state: string | boolean; 24 | } 25 | 26 | export type KnxEntityData = SwitchEntityData; 27 | 28 | export interface EntityData { 29 | entity: BaseEntityData; 30 | knx: KnxEntityData; 31 | } 32 | 33 | export interface CreateEntityData { 34 | platform: SupportedPlatform; 35 | data: EntityData; 36 | } 37 | 38 | export interface UpdateEntityData extends CreateEntityData { 39 | entity_id: string; 40 | } 41 | 42 | export interface DeviceCreateData { 43 | name: string; 44 | area_id?: string; 45 | } 46 | 47 | // ################# 48 | // Validation result 49 | // ################# 50 | 51 | export interface ErrorDescription { 52 | path: string[] | null; 53 | error_message: string; 54 | error_class: string; 55 | } 56 | 57 | export type CreateEntityResult = 58 | | { 59 | success: true; 60 | entity_id: string | null; 61 | } 62 | | { 63 | success: false; 64 | error_base: string; 65 | errors: ErrorDescription[]; 66 | }; 67 | -------------------------------------------------------------------------------- /src/types/knx.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigEntry } from "@ha/data/config_entries"; 2 | import type { KNXInfoData, KNXProjectResponse } from "./websocket"; 3 | 4 | export interface KNX { 5 | language: string; 6 | config_entry: ConfigEntry; 7 | localize(string: string, replace?: Record): string; 8 | log: any; 9 | info: KNXInfoData; 10 | project: KNXProjectResponse | null; 11 | loadProject(): Promise; 12 | } 13 | -------------------------------------------------------------------------------- /src/types/navigation.ts: -------------------------------------------------------------------------------- 1 | export interface Route { 2 | path: string; 3 | prefix: string; 4 | } 5 | 6 | export interface LocationChangedEvent { 7 | detail?: { route: Route; force?: boolean }; 8 | } 9 | -------------------------------------------------------------------------------- /src/types/websocket.ts: -------------------------------------------------------------------------------- 1 | export interface KNXInfoData { 2 | version: string; 3 | connected: boolean; 4 | current_address: string; 5 | project: KNXProjectInfo | null; 6 | } 7 | 8 | export interface KNXProjectInfo { 9 | name: string; 10 | last_modified: string; 11 | tool_version: string; 12 | xknxproject_version: string; 13 | } 14 | 15 | export interface GroupMonitorInfoData { 16 | project_loaded: boolean; 17 | recent_telegrams: TelegramDict[]; 18 | } 19 | 20 | // this has to match `TelegramDict` in the integrations `telegram.py` 21 | export interface TelegramDict { 22 | destination: string; 23 | destination_name: string; 24 | direction: string; 25 | dpt_main: number | null; 26 | dpt_sub: number | null; 27 | dpt_name: string | null; 28 | source: string; 29 | source_name: string; 30 | payload: number | number[] | null; 31 | telegramtype: string; 32 | timestamp: string; // ISO 8601 eg. "2023-06-21T22:28:45.446257+02:00" from `dt_util.as_local(dt_util.utcnow())` 33 | unit: string | null; 34 | value: string | number | boolean | null; 35 | } 36 | 37 | export interface KNXProjectResponse { 38 | project_loaded: boolean; 39 | knxproject: KNXProject; 40 | } 41 | 42 | export interface KNXProject { 43 | info: KNXProjectInfo; 44 | group_addresses: Record; 45 | group_ranges: Record; 46 | devices: Record; 47 | communication_objects: Record; 48 | } 49 | 50 | export interface GroupRange { 51 | name: string; 52 | address_start: number; 53 | address_end: number; 54 | comment: string; 55 | group_addresses: string[]; 56 | group_ranges: Record; 57 | } 58 | 59 | export interface GroupAddress { 60 | name: string; 61 | identifier: string; 62 | raw_address: number; 63 | address: string; 64 | project_uid: number; 65 | dpt: DPT | null; 66 | communication_object_ids: string[]; 67 | description: string; 68 | comment: string; 69 | } 70 | 71 | export interface DPT { 72 | main: number; 73 | sub: number | null; 74 | } 75 | 76 | export interface Device { 77 | name: string; 78 | hardware_name: string; 79 | description: string; 80 | manufacturer_name: string; 81 | individual_address: string; 82 | application: string | null; 83 | project_uid: number | null; 84 | communication_object_ids: string[]; 85 | channels: Record; // id: Channel 86 | } 87 | 88 | export interface Channel { 89 | identifier: string; 90 | name: string; 91 | } 92 | 93 | export interface CommunicationObject { 94 | name: string; 95 | number: number; 96 | text: string; 97 | function_text: string; 98 | description: string; 99 | device_address: string; 100 | device_application: string | null; 101 | module: ModuleInstanceInfos | null; 102 | channel: string | null; 103 | dpts: DPT[]; 104 | object_size: string; 105 | group_address_links: string[]; 106 | flags: COFlags; 107 | } 108 | 109 | interface ModuleInstanceInfos { 110 | definition: string; 111 | root_number: number; // `Number` assigned by ComObject - without Module base object number added 112 | } 113 | 114 | export interface COFlags { 115 | read: boolean; 116 | write: boolean; 117 | communication: boolean; 118 | transmit: boolean; 119 | update: boolean; 120 | readOnInit: boolean; 121 | } 122 | -------------------------------------------------------------------------------- /src/utils/common.ts: -------------------------------------------------------------------------------- 1 | import { mdiToggleSwitchVariant, mdiCheckCircle, mdiWindowShutter } from "@mdi/js"; 2 | import { FALLBACK_DOMAIN_ICONS } from "@ha/data/icons"; 3 | import * as schema from "./schema"; 4 | import type { SupportedPlatform } from "../types/entity_data"; 5 | 6 | export interface PlatformInfo { 7 | name: string; 8 | iconPath: string; 9 | color: string; 10 | description?: string; 11 | schema: schema.SettingsGroup[]; 12 | } 13 | 14 | export const platformConstants: Record = { 15 | binary_sensor: { 16 | name: "Binary Sensor", 17 | iconPath: mdiCheckCircle, 18 | color: "var(--green-color)", 19 | description: "Read-only entity for binary datapoints. Window or door states etc.", 20 | schema: schema.binarySensorSchema, 21 | }, 22 | switch: { 23 | name: "Switch", 24 | iconPath: mdiToggleSwitchVariant, 25 | color: "var(--blue-color)", 26 | description: "The KNX switch platform is used as an interface to switching actuators.", 27 | schema: schema.switchSchema, 28 | }, 29 | light: { 30 | name: "Light", 31 | iconPath: FALLBACK_DOMAIN_ICONS.light, 32 | color: "var(--amber-color)", 33 | description: 34 | "The KNX light platform is used as an interface to dimming actuators, LED controllers, DALI gateways and similar.", 35 | schema: schema.lightSchema, 36 | }, 37 | cover: { 38 | name: "Cover", 39 | iconPath: mdiWindowShutter, 40 | color: "var(--cyan-color)", 41 | description: "The KNX cover platform is used as an interface to shutter actuators.", 42 | schema: schema.coverSchema, 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /src/utils/device.ts: -------------------------------------------------------------------------------- 1 | import type { HomeAssistant } from "@ha/types"; 2 | import type { DeviceRegistryEntry } from "@ha/data/device_registry"; 3 | 4 | const isKnxIdentifier = (identifier: [string, string]): boolean => identifier[0] === "knx"; 5 | 6 | const isKnxDevice = (device: DeviceRegistryEntry): boolean => 7 | device.identifiers.some(isKnxIdentifier); 8 | 9 | export const knxDevices = (hass: HomeAssistant): DeviceRegistryEntry[] => 10 | Object.values(hass.devices).filter(isKnxDevice); 11 | 12 | export const deviceFromIdentifier = ( 13 | hass: HomeAssistant, 14 | identifier: string, 15 | ): DeviceRegistryEntry | undefined => { 16 | const deviceEntry = Object.values(hass.devices).find((entry) => 17 | entry.identifiers.find( 18 | (deviceIdentifier) => isKnxIdentifier(deviceIdentifier) && deviceIdentifier[1] === identifier, 19 | ), 20 | ); 21 | return deviceEntry; 22 | }; 23 | 24 | export const getKnxDeviceIdentifier = (deviceEntry: DeviceRegistryEntry): string | undefined => { 25 | const knxIdentifier = deviceEntry.identifiers.find(isKnxIdentifier); 26 | return knxIdentifier ? knxIdentifier[1] : undefined; 27 | }; 28 | -------------------------------------------------------------------------------- /src/utils/dpt.ts: -------------------------------------------------------------------------------- 1 | import type { DPT, KNXProject, CommunicationObject, GroupAddress } from "../types/websocket"; 2 | import type { SettingsGroup } from "./schema"; 3 | 4 | export const equalDPT = (dpt1: DPT, dpt2: DPT): boolean => 5 | dpt1.main === dpt2.main && dpt1.sub === dpt2.sub; 6 | 7 | export const isValidDPT = (testDPT: DPT, validDPTs: DPT[]): boolean => 8 | // true if main and sub is equal to one validDPT or 9 | // if main is equal to one validDPT where sub is `null` 10 | validDPTs.some( 11 | (testValidDPT) => 12 | testDPT.main === testValidDPT.main && 13 | (testValidDPT.sub ? testDPT.sub === testValidDPT.sub : true), 14 | ); 15 | 16 | export const filterValidGroupAddresses = ( 17 | project: KNXProject, 18 | validDPTs: DPT[], 19 | ): Record => 20 | Object.entries(project.group_addresses).reduce( 21 | (acc, [id, groupAddress]) => { 22 | if (groupAddress.dpt && isValidDPT(groupAddress.dpt, validDPTs)) { 23 | acc[id] = groupAddress; 24 | } 25 | return acc; 26 | }, 27 | {} as Record, 28 | ); 29 | 30 | export const filterValidComObjects = ( 31 | project: KNXProject, 32 | validDPTs: DPT[], 33 | ): Record => { 34 | const validGroupAddresses = filterValidGroupAddresses(project, validDPTs); 35 | return Object.entries(project.communication_objects).reduce( 36 | (acc, [id, comObject]) => { 37 | if (comObject.group_address_links.some((gaLink) => gaLink in validGroupAddresses)) { 38 | acc[id] = comObject; 39 | } 40 | return acc; 41 | }, 42 | {} as Record, 43 | ); 44 | }; 45 | 46 | export const filterDupicateDPTs = (dpts: DPT[]): DPT[] => 47 | dpts.reduce( 48 | (acc, dpt) => (acc.some((resultDpt) => equalDPT(resultDpt, dpt)) ? acc : acc.concat([dpt])), 49 | [] as DPT[], 50 | ); 51 | 52 | export const validDPTsForSchema = (schema: SettingsGroup[]): DPT[] => { 53 | const result: DPT[] = []; 54 | schema.forEach((group) => { 55 | group.selectors.forEach((selector) => { 56 | if (selector.type === "group_address") { 57 | if (selector.options.validDPTs) { 58 | result.push(...selector.options.validDPTs); 59 | } else if (selector.options.dptSelect) { 60 | result.push(...selector.options.dptSelect.map((dptOption) => dptOption.dpt)); 61 | } 62 | } 63 | }); 64 | }); 65 | return filterDupicateDPTs(result); 66 | }; 67 | -------------------------------------------------------------------------------- /src/utils/drag-drop-context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "@lit/context"; 2 | import type { GroupAddress } from "../types/websocket"; 3 | import { KNXLogger } from "../tools/knx-logger"; 4 | 5 | const logger = new KNXLogger("knx-drag-drop-context"); 6 | 7 | const contextKey = Symbol("drag-drop-context"); 8 | 9 | export class DragDropContext { 10 | _groupAddress?: GroupAddress; 11 | 12 | _updateObservers: () => void; 13 | 14 | constructor(updateObservers: () => void) { 15 | // call the context providers updateObservers method to trigger 16 | // reactive updates from consumers drag events to other subscribed consumers 17 | this._updateObservers = updateObservers; 18 | } 19 | 20 | get groupAddress(): GroupAddress | undefined { 21 | return this._groupAddress; 22 | } 23 | 24 | // arrow function => so `this` refers to the class instance, not the event source 25 | public gaDragStartHandler = (ev: DragEvent) => { 26 | const target = ev.target as HTMLElement; 27 | const ga = target.ga as GroupAddress; 28 | if (!ga) { 29 | logger.warn("dragstart: no 'ga' property found", target); 30 | return; 31 | } 32 | this._groupAddress = ga; 33 | logger.debug("dragstart", ga.address, this); 34 | ev.dataTransfer?.setData("text/group-address", ga.address); 35 | this._updateObservers(); 36 | }; 37 | 38 | public gaDragEndHandler = (_ev: DragEvent) => { 39 | logger.debug("dragend", this); 40 | this._groupAddress = undefined; 41 | this._updateObservers(); 42 | }; 43 | 44 | public gaDragIndicatorStartHandler = (ev: MouseEvent) => { 45 | const target = ev.target as HTMLElement; 46 | const ga = target.ga as GroupAddress; 47 | if (!ga) { 48 | return; 49 | } 50 | this._groupAddress = ga; 51 | logger.debug("drag indicator start", ga.address, this); 52 | this._updateObservers(); 53 | }; 54 | 55 | public gaDragIndicatorEndHandler = (_ev: MouseEvent) => { 56 | logger.debug("drag indicator end", this); 57 | this._groupAddress = undefined; 58 | this._updateObservers(); 59 | }; 60 | } 61 | 62 | export const dragDropContext = createContext(contextKey); 63 | -------------------------------------------------------------------------------- /src/utils/format.ts: -------------------------------------------------------------------------------- 1 | import { dump } from "js-yaml"; 2 | import type { DPT, TelegramDict } from "../types/websocket"; 3 | 4 | export const TelegramDictFormatter = { 5 | payload: (telegram: TelegramDict): string => { 6 | if (telegram.payload == null) return ""; 7 | return Array.isArray(telegram.payload) 8 | ? telegram.payload.reduce((res, curr) => res + curr.toString(16).padStart(2, "0"), "0x") 9 | : telegram.payload.toString(); 10 | }, 11 | 12 | valueWithUnit: (telegram: TelegramDict): string => { 13 | if (telegram.value == null) return ""; 14 | if ( 15 | typeof telegram.value === "number" || 16 | typeof telegram.value === "boolean" || 17 | typeof telegram.value === "string" 18 | ) { 19 | return telegram.value.toString() + (telegram.unit ? " " + telegram.unit : ""); 20 | } 21 | return dump(telegram.value); 22 | }, 23 | 24 | timeWithMilliseconds: (telegram: TelegramDict): string => { 25 | const date = new Date(telegram.timestamp); 26 | return date.toLocaleTimeString(["en-US"], { 27 | hour12: false, 28 | hour: "2-digit", 29 | minute: "2-digit", 30 | second: "2-digit", 31 | fractionalSecondDigits: 3, 32 | }); 33 | }, 34 | 35 | dateWithMilliseconds: (telegram: TelegramDict): string => { 36 | const date = new Date(telegram.timestamp); 37 | return date.toLocaleTimeString([], { 38 | year: "numeric", 39 | month: "2-digit", 40 | day: "2-digit", 41 | hour: "2-digit", 42 | minute: "2-digit", 43 | second: "2-digit", 44 | fractionalSecondDigits: 3, 45 | }); 46 | }, 47 | 48 | dptNumber: (telegram: TelegramDict): string => { 49 | if (telegram.dpt_main == null) return ""; 50 | return telegram.dpt_sub == null 51 | ? telegram.dpt_main.toString() 52 | : telegram.dpt_main.toString() + "." + telegram.dpt_sub.toString().padStart(3, "0"); 53 | }, 54 | 55 | dptNameNumber: (telegram: TelegramDict): string => { 56 | const dptNumber = TelegramDictFormatter.dptNumber(telegram); 57 | if (telegram.dpt_name == null) return `DPT ${dptNumber}`; 58 | return dptNumber ? `DPT ${dptNumber} ${telegram.dpt_name}` : telegram.dpt_name; 59 | }, 60 | }; 61 | 62 | export const dptToString = (dpt: DPT | null): string => { 63 | if (dpt == null) return ""; 64 | return dpt.main + (dpt.sub ? "." + dpt.sub.toString().padStart(3, "0") : ""); 65 | }; 66 | -------------------------------------------------------------------------------- /src/utils/validation.ts: -------------------------------------------------------------------------------- 1 | import type { ErrorDescription } from "../types/entity_data"; 2 | 3 | export const extractValidationErrors = ( 4 | errors: ErrorDescription[] | undefined, 5 | itemName: string, 6 | ): ErrorDescription[] | undefined => { 7 | if (!errors) { 8 | return undefined; 9 | } 10 | 11 | const errorsForItem: ErrorDescription[] = []; 12 | 13 | for (const error of errors) { 14 | if (error.path) { 15 | const [pathHead, ...pathTail] = error.path; 16 | if (pathHead === itemName) { 17 | errorsForItem.push({ ...error, path: pathTail }); 18 | } 19 | } 20 | } 21 | 22 | return errorsForItem.length ? errorsForItem : undefined; 23 | }; 24 | 25 | // group select validation errors may have no "write" or "state" path key since the ga-key isn't in config 26 | // so we show a general exception. 27 | // When `itemName` is undefined, this gets the general error for a group address item without "write" or "state" key 28 | // if an `itemName` is provided, it will return the error for that item. 29 | export const getValidationError = ( 30 | errors: ErrorDescription[] | undefined, 31 | itemName: string | undefined = undefined, 32 | ): ErrorDescription | undefined => { 33 | if (itemName) { 34 | errors = extractValidationErrors(errors, itemName); 35 | } 36 | return errors?.find((error) => error.path?.length === 0); 37 | }; 38 | -------------------------------------------------------------------------------- /src/version.ts: -------------------------------------------------------------------------------- 1 | // This is replaced by git tag name on release. See ReleaseActions.yml 2 | export const VERSION = "dev"; 3 | -------------------------------------------------------------------------------- /src/views/entities_router.ts: -------------------------------------------------------------------------------- 1 | import { customElement } from "lit/decorators"; 2 | 3 | import type { RouterOptions } from "@ha/layouts/hass-router-page"; 4 | 5 | import { KnxRouter } from "../knx-router"; 6 | import { KNXLogger } from "../tools/knx-logger"; 7 | 8 | const logger = new KNXLogger("router"); 9 | 10 | @customElement("knx-entities-router") 11 | class KnxEntitiesRouter extends KnxRouter { 12 | protected routerOptions: RouterOptions = { 13 | defaultPage: "view", 14 | beforeRender: (page: string) => (page === "" ? this.routerOptions.defaultPage : undefined), 15 | routes: { 16 | view: { 17 | tag: "knx-entities-view", 18 | load: () => { 19 | logger.debug("Importing knx-entities-view"); 20 | return import("./entities_view"); 21 | }, 22 | }, 23 | create: { 24 | tag: "knx-create-entity", 25 | load: () => { 26 | logger.debug("Importing knx-create-entity"); 27 | return import("./entities_create"); 28 | }, 29 | }, 30 | edit: { 31 | tag: "knx-create-entity", 32 | load: () => { 33 | logger.debug("Importing knx-create-entity"); 34 | return import("./entities_create"); 35 | }, 36 | }, 37 | }, 38 | }; 39 | } 40 | 41 | declare global { 42 | interface HTMLElementTagNameMap { 43 | "knx-entities-router": KnxEntitiesRouter; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/views/entities_view.ts: -------------------------------------------------------------------------------- 1 | import { mdiDelete, mdiInformationSlabCircleOutline, mdiPlus, mdiPencilOutline } from "@mdi/js"; 2 | import type { TemplateResult } from "lit"; 3 | import { LitElement, html, css } from "lit"; 4 | import { customElement, property, state } from "lit/decorators"; 5 | 6 | import type { HassEntity } from "home-assistant-js-websocket"; 7 | import memoize from "memoize-one"; 8 | 9 | import "@ha/layouts/hass-loading-screen"; 10 | import "@ha/layouts/hass-tabs-subpage-data-table"; 11 | import "@ha/components/ha-fab"; 12 | import "@ha/components/ha-icon-button"; 13 | import "@ha/components/ha-state-icon"; 14 | import "@ha/components/ha-svg-icon"; 15 | import { navigate } from "@ha/common/navigate"; 16 | import { mainWindow } from "@ha/common/dom/get_main_window"; 17 | import { fireEvent } from "@ha/common/dom/fire_event"; 18 | import type { DataTableColumnContainer } from "@ha/components/data-table/ha-data-table"; 19 | import type { ExtEntityRegistryEntry } from "@ha/data/entity_registry"; 20 | import { showAlertDialog, showConfirmationDialog } from "@ha/dialogs/generic/show-dialog-box"; 21 | import type { PageNavigation } from "@ha/layouts/hass-tabs-subpage"; 22 | import type { HomeAssistant, Route } from "@ha/types"; 23 | 24 | import { getEntityEntries, deleteEntity } from "../services/websocket.service"; 25 | import type { KNX } from "../types/knx"; 26 | import { KNXLogger } from "../tools/knx-logger"; 27 | 28 | const logger = new KNXLogger("knx-entities-view"); 29 | 30 | export interface EntityRow extends ExtEntityRegistryEntry { 31 | entityState?: HassEntity; 32 | friendly_name: string; 33 | device_name: string; 34 | area_name: string; 35 | } 36 | 37 | @customElement("knx-entities-view") 38 | export class KNXEntitiesView extends LitElement { 39 | @property({ type: Object }) public hass!: HomeAssistant; 40 | 41 | @property({ attribute: false }) public knx!: KNX; 42 | 43 | @property({ type: Boolean, reflect: true }) public narrow!: boolean; 44 | 45 | @property({ type: Object }) public route?: Route; 46 | 47 | @property({ type: Array, reflect: false }) public tabs!: PageNavigation[]; 48 | 49 | @state() private knx_entities: EntityRow[] = []; 50 | 51 | @state() private filterDevice: string | null = null; 52 | 53 | protected firstUpdated() { 54 | this._fetchEntities(); 55 | } 56 | 57 | protected willUpdate() { 58 | const urlParams = new URLSearchParams(mainWindow.location.search); 59 | this.filterDevice = urlParams.get("device_id"); 60 | } 61 | 62 | private async _fetchEntities() { 63 | getEntityEntries(this.hass) 64 | .then((entries) => { 65 | logger.debug(`Fetched ${entries.length} entity entries.`); 66 | this.knx_entities = entries.map((entry) => { 67 | const entityState = this.hass.states[entry.entity_id]; 68 | const device = entry.device_id ? this.hass.devices[entry.device_id] : undefined; 69 | const areaId = entry.area_id ?? device?.area_id; 70 | const area = areaId ? this.hass.areas[areaId] : undefined; 71 | return { 72 | ...entry, 73 | entityState, 74 | friendly_name: entityState.attributes.friendly_name ?? entry.name ?? "", 75 | device_name: device?.name ?? "", 76 | area_name: area?.name ?? "", 77 | }; 78 | }); 79 | }) 80 | .catch((err) => { 81 | logger.error("getEntityEntries", err); 82 | navigate("/knx/error", { replace: true, data: err }); 83 | }); 84 | } 85 | 86 | private _columns = memoize((_language): DataTableColumnContainer => { 87 | const iconWidth = "56px"; 88 | const actionWidth = "176px"; // 48px*3 + 16px*2 padding 89 | 90 | return { 91 | icon: { 92 | title: "", 93 | minWidth: iconWidth, 94 | maxWidth: iconWidth, 95 | type: "icon", 96 | template: (entry) => html` 97 | 102 | `, 103 | }, 104 | friendly_name: { 105 | showNarrow: true, 106 | filterable: true, 107 | sortable: true, 108 | title: "Friendly Name", 109 | flex: 2, 110 | // sorting didn't work properly with templates 111 | }, 112 | entity_id: { 113 | filterable: true, 114 | sortable: true, 115 | title: "Entity ID", 116 | flex: 1, 117 | }, 118 | device_name: { 119 | filterable: true, 120 | sortable: true, 121 | title: "Device", 122 | flex: 1, 123 | }, 124 | device_id: { 125 | hidden: true, // for filtering only 126 | title: "Device ID", 127 | filterable: true, 128 | template: (entry) => entry.device_id ?? "", 129 | }, 130 | area_name: { 131 | title: "Area", 132 | sortable: true, 133 | filterable: true, 134 | flex: 1, 135 | }, 136 | actions: { 137 | showNarrow: true, 138 | title: "", 139 | minWidth: actionWidth, 140 | maxWidth: actionWidth, 141 | type: "icon-button", 142 | template: (entry) => html` 143 | 149 | 155 | 161 | `, 162 | }, 163 | }; 164 | }); 165 | 166 | private _entityEdit = (ev: Event) => { 167 | ev.stopPropagation(); 168 | const entry = ev.target.entityEntry as EntityRow; 169 | navigate("/knx/entities/edit/" + entry.entity_id); 170 | }; 171 | 172 | private _entityMoreInfo = (ev: Event) => { 173 | ev.stopPropagation(); 174 | const entry = ev.target.entityEntry as EntityRow; 175 | fireEvent(mainWindow.document.querySelector("home-assistant")!, "hass-more-info", { 176 | entityId: entry.entity_id, 177 | }); 178 | }; 179 | 180 | private _entityDelete = (ev: Event) => { 181 | ev.stopPropagation(); 182 | const entry = ev.target.entityEntry as EntityRow; 183 | showConfirmationDialog(this, { 184 | text: `${this.hass.localize("ui.common.delete")} ${entry.entity_id}?`, 185 | }).then((confirmed) => { 186 | if (confirmed) { 187 | deleteEntity(this.hass, entry.entity_id) 188 | .then(() => { 189 | logger.debug("entity deleted", entry.entity_id); 190 | this._fetchEntities(); 191 | }) 192 | .catch((err: any) => { 193 | showAlertDialog(this, { 194 | title: "Deletion failed", 195 | text: err, 196 | }); 197 | }); 198 | } 199 | }); 200 | }; 201 | 202 | protected render(): TemplateResult { 203 | if (!this.hass || !this.knx_entities) { 204 | return html` `; 205 | } 206 | 207 | return html` 208 | 221 | 227 | 228 | 229 | 230 | `; 231 | } 232 | 233 | private _entityCreate() { 234 | navigate("/knx/entities/create"); 235 | } 236 | 237 | static styles = css` 238 | hass-loading-screen { 239 | --app-header-background-color: var(--sidebar-background-color); 240 | --app-header-text-color: var(--sidebar-text-color); 241 | } 242 | `; 243 | } 244 | 245 | declare global { 246 | interface HTMLElementTagNameMap { 247 | "knx-entities-view": KNXEntitiesView; 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /src/views/error.ts: -------------------------------------------------------------------------------- 1 | import type { TemplateResult } from "lit"; 2 | import { html, LitElement } from "lit"; 3 | import { customElement, property } from "lit/decorators"; 4 | 5 | import { mainWindow } from "@ha/common/dom/get_main_window"; 6 | import "@ha/layouts/hass-tabs-subpage"; 7 | import "@ha/layouts/hass-error-screen"; 8 | 9 | import type { PageNavigation } from "@ha/layouts/hass-tabs-subpage"; 10 | import type { HomeAssistant, Route } from "@ha/types"; 11 | 12 | import type { KNX } from "../types/knx"; 13 | 14 | @customElement("knx-error") 15 | export class KNXError extends LitElement { 16 | @property({ type: Object }) public hass!: HomeAssistant; 17 | 18 | @property({ attribute: false }) public knx!: KNX; 19 | 20 | @property({ type: Boolean, reflect: true }) public narrow!: boolean; 21 | 22 | @property({ type: Object }) public route?: Route; 23 | 24 | @property({ type: Array, reflect: false }) public tabs!: PageNavigation[]; 25 | 26 | protected render(): TemplateResult { 27 | const error = mainWindow.history.state?.message ?? "Unknown error"; 28 | return html` 29 | 36 | `; 37 | } 38 | } 39 | 40 | declare global { 41 | interface HTMLElementTagNameMap { 42 | "knx-error": KNXError; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/views/group_monitor.ts: -------------------------------------------------------------------------------- 1 | import type { CSSResultGroup, TemplateResult } from "lit"; 2 | import { html, LitElement, nothing } from "lit"; 3 | import { customElement, property, state } from "lit/decorators"; 4 | 5 | import { mdiPause, mdiFastForward } from "@mdi/js"; 6 | import memoize from "memoize-one"; 7 | 8 | import "@ha/layouts/hass-loading-screen"; 9 | import "@ha/layouts/hass-tabs-subpage-data-table"; 10 | import type { HASSDomEvent } from "@ha/common/dom/fire_event"; 11 | import { navigate } from "@ha/common/navigate"; 12 | import type { 13 | DataTableColumnContainer, 14 | DataTableRowData, 15 | RowClickedEvent, 16 | } from "@ha/components/data-table/ha-data-table"; 17 | import "@ha/components/ha-icon-button"; 18 | import { haStyle } from "@ha/resources/styles"; 19 | import type { HomeAssistant, Route } from "@ha/types"; 20 | import type { PageNavigation } from "@ha/layouts/hass-tabs-subpage"; 21 | import { subscribeKnxTelegrams, getGroupMonitorInfo } from "../services/websocket.service"; 22 | import type { KNX } from "../types/knx"; 23 | import type { TelegramDict } from "../types/websocket"; 24 | import { TelegramDictFormatter } from "../utils/format"; 25 | import "../dialogs/knx-telegram-info-dialog"; 26 | import { KNXLogger } from "../tools/knx-logger"; 27 | 28 | const logger = new KNXLogger("group_monitor"); 29 | 30 | @customElement("knx-group-monitor") 31 | export class KNXGroupMonitor extends LitElement { 32 | @property({ type: Object }) public hass!: HomeAssistant; 33 | 34 | @property({ attribute: false }) public knx!: KNX; 35 | 36 | @property({ type: Boolean, reflect: true }) public narrow!: boolean; 37 | 38 | @property({ type: Object }) public route?: Route; 39 | 40 | @property({ type: Array, reflect: false }) public tabs!: PageNavigation[]; 41 | 42 | @state() private projectLoaded = false; 43 | 44 | @state() private subscribed?: () => void; 45 | 46 | @state() private telegrams: TelegramDict[] = []; 47 | 48 | @state() private rows: DataTableRowData[] = []; 49 | 50 | @state() private _dialogIndex: number | null = null; 51 | 52 | @state() private _pause = false; 53 | 54 | public disconnectedCallback() { 55 | super.disconnectedCallback(); 56 | if (this.subscribed) { 57 | this.subscribed(); 58 | this.subscribed = undefined; 59 | } 60 | } 61 | 62 | protected async firstUpdated() { 63 | if (!this.subscribed) { 64 | getGroupMonitorInfo(this.hass) 65 | .then((groupMonitorInfo) => { 66 | this.projectLoaded = groupMonitorInfo.project_loaded; 67 | this.telegrams = groupMonitorInfo.recent_telegrams; 68 | this.rows = this.telegrams.map((telegram, index) => this._telegramToRow(telegram, index)); 69 | }) 70 | .catch((err) => { 71 | logger.error("getGroupMonitorInfo", err); 72 | navigate("/knx/error", { replace: true, data: err }); 73 | }); 74 | this.subscribed = await subscribeKnxTelegrams(this.hass, (message) => { 75 | this.telegram_callback(message); 76 | this.requestUpdate(); 77 | }); 78 | } 79 | } 80 | 81 | private _columns = memoize( 82 | (narrow, projectLoaded, _language): DataTableColumnContainer => ({ 83 | index: { 84 | showNarrow: false, 85 | title: "#", 86 | sortable: true, 87 | direction: "desc", 88 | type: "numeric", 89 | minWidth: "68px", // 5 digits 90 | maxWidth: "68px", 91 | }, 92 | timestamp: { 93 | showNarrow: false, 94 | filterable: true, 95 | sortable: true, 96 | title: this.knx.localize("group_monitor_time"), 97 | minWidth: "110px", 98 | maxWidth: "110px", 99 | }, 100 | sourceAddress: { 101 | showNarrow: true, 102 | filterable: true, 103 | sortable: true, 104 | title: this.knx.localize("group_monitor_source"), 105 | flex: 2, 106 | minWidth: "0", // prevent horizontal scroll on very narrow screens 107 | template: (row) => 108 | projectLoaded 109 | ? html`
${row.sourceAddress}
110 |
${row.sourceText}
` 111 | : row.sourceAddress, 112 | }, 113 | sourceText: { 114 | hidden: true, 115 | filterable: true, 116 | sortable: true, 117 | title: this.knx.localize("group_monitor_source"), 118 | }, 119 | destinationAddress: { 120 | showNarrow: true, 121 | sortable: true, 122 | filterable: true, 123 | title: this.knx.localize("group_monitor_destination"), 124 | flex: 2, 125 | minWidth: "0", // prevent horizontal scroll on very narrow screens 126 | template: (row) => 127 | projectLoaded 128 | ? html`
${row.destinationAddress}
129 |
${row.destinationText}
` 130 | : row.destinationAddress, 131 | }, 132 | destinationText: { 133 | showNarrow: true, 134 | hidden: true, 135 | sortable: true, 136 | filterable: true, 137 | title: this.knx.localize("group_monitor_destination"), 138 | }, 139 | type: { 140 | showNarrow: false, 141 | title: this.knx.localize("group_monitor_type"), 142 | filterable: true, 143 | minWidth: "155px", // 155px suits for "GroupValueResponse" 144 | maxWidth: "155px", 145 | template: (row) => 146 | html`
${row.type}
147 |
${row.direction}
`, 148 | }, 149 | payload: { 150 | showNarrow: false, 151 | hidden: narrow && projectLoaded, 152 | title: this.knx.localize("group_monitor_payload"), 153 | filterable: true, 154 | type: "numeric", 155 | minWidth: "105px", 156 | maxWidth: "105px", 157 | }, 158 | value: { 159 | showNarrow: true, 160 | hidden: !projectLoaded, 161 | title: this.knx.localize("group_monitor_value"), 162 | filterable: true, 163 | flex: 1, 164 | minWidth: "0", // prevent horizontal scroll on very narrow screens 165 | }, 166 | }), 167 | ); 168 | 169 | protected telegram_callback(telegram: TelegramDict): void { 170 | this.telegrams.push(telegram); 171 | if (this._pause) return; 172 | const rows = [...this.rows]; 173 | rows.push(this._telegramToRow(telegram, rows.length)); 174 | this.rows = rows; 175 | } 176 | 177 | protected _telegramToRow(telegram: TelegramDict, index: number): DataTableRowData { 178 | const value = TelegramDictFormatter.valueWithUnit(telegram); 179 | const payload = TelegramDictFormatter.payload(telegram); 180 | return { 181 | index: index, 182 | destinationAddress: telegram.destination, 183 | destinationText: telegram.destination_name, 184 | direction: this.knx.localize(telegram.direction), 185 | payload: payload, 186 | sourceAddress: telegram.source, 187 | sourceText: telegram.source_name, 188 | timestamp: TelegramDictFormatter.timeWithMilliseconds(telegram), 189 | type: telegram.telegramtype, 190 | value: !this.narrow 191 | ? value 192 | : value || payload || (telegram.telegramtype === "GroupValueRead" ? "GroupRead" : ""), 193 | }; 194 | } 195 | 196 | protected render(): TemplateResult { 197 | if (this.subscribed === undefined) { 198 | return html` 201 | `; 202 | } 203 | return html` 204 | 219 | 225 | 226 | ${this._dialogIndex !== null ? this._renderTelegramInfoDialog(this._dialogIndex) : nothing} 227 | `; 228 | } 229 | 230 | private _togglePause(): void { 231 | this._pause = !this._pause; 232 | if (!this._pause) { 233 | const currentRowCount = this.rows.length; 234 | const pauseTelegrams = this.telegrams.slice(currentRowCount); 235 | this.rows = this.rows.concat( 236 | pauseTelegrams.map((telegram, index) => 237 | this._telegramToRow(telegram, currentRowCount + index), 238 | ), 239 | ); 240 | } 241 | } 242 | 243 | private _renderTelegramInfoDialog(index: number): TemplateResult { 244 | return html` = this.telegrams.length} 250 | .disablePrevious=${index <= 0} 251 | @next-telegram=${this._dialogNext} 252 | @previous-telegram=${this._dialogPrevious} 253 | @dialog-closed=${this._dialogClosed} 254 | >`; 255 | } 256 | 257 | private async _rowClicked(ev: HASSDomEvent): Promise { 258 | const telegramIndex = Number(ev.detail.id); 259 | this._dialogIndex = telegramIndex; 260 | } 261 | 262 | private _dialogNext(): void { 263 | this._dialogIndex = this._dialogIndex! + 1; 264 | } 265 | 266 | private _dialogPrevious(): void { 267 | this._dialogIndex = this._dialogIndex! - 1; 268 | } 269 | 270 | private _dialogClosed(): void { 271 | this._dialogIndex = null; 272 | } 273 | 274 | static get styles(): CSSResultGroup { 275 | return haStyle; 276 | } 277 | } 278 | 279 | declare global { 280 | interface HTMLElementTagNameMap { 281 | "knx-group-monitor": KNXGroupMonitor; 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /src/views/info.ts: -------------------------------------------------------------------------------- 1 | import { mdiFileUpload } from "@mdi/js"; 2 | import type { TemplateResult } from "lit"; 3 | import { css, nothing, html, LitElement } from "lit"; 4 | import { customElement, property, state } from "lit/decorators"; 5 | 6 | import { fireEvent } from "@ha/common/dom/fire_event"; 7 | import "@ha/components/ha-card"; 8 | import "@ha/layouts/hass-tabs-subpage"; 9 | import type { PageNavigation } from "@ha/layouts/hass-tabs-subpage"; 10 | import "@ha/components/ha-button"; 11 | import "@ha/components/ha-file-upload"; 12 | import "@ha/components/ha-selector/ha-selector-text"; 13 | import { uploadFile } from "@ha/data/file_upload"; 14 | import { extractApiErrorMessage } from "@ha/data/hassio/common"; 15 | import { showAlertDialog, showConfirmationDialog } from "@ha/dialogs/generic/show-dialog-box"; 16 | import type { HomeAssistant, Route } from "@ha/types"; 17 | 18 | import { processProjectFile, removeProjectFile } from "../services/websocket.service"; 19 | 20 | import type { KNX } from "../types/knx"; 21 | import type { KNXProjectInfo } from "../types/websocket"; 22 | import { KNXLogger } from "../tools/knx-logger"; 23 | import { VERSION } from "../version"; 24 | 25 | const logger = new KNXLogger("info"); 26 | 27 | @customElement("knx-info") 28 | export class KNXInfo extends LitElement { 29 | @property({ type: Object }) public hass!: HomeAssistant; 30 | 31 | @property({ attribute: false }) public knx!: KNX; 32 | 33 | @property({ type: Boolean, reflect: true }) public narrow!: boolean; 34 | 35 | @property({ type: Object }) public route?: Route; 36 | 37 | @property({ type: Array, reflect: false }) public tabs!: PageNavigation[]; 38 | 39 | @state() private _projectPassword?: string; 40 | 41 | @state() private _uploading = false; 42 | 43 | @state() private _projectFile?: File; 44 | 45 | protected render(): TemplateResult { 46 | return html` 47 | 54 |
55 | ${this._renderInfoCard()} 56 | ${this.knx.info.project ? this._renderProjectDataCard(this.knx.info.project) : nothing} 57 | ${this._renderProjectUploadCard()} 58 |
59 |
60 | `; 61 | } 62 | 63 | private _renderInfoCard() { 64 | return html` 65 |
66 |
${this.knx.localize("info_information_header")}
67 | 68 |
69 |
XKNX Version
70 |
${this.knx.info.version}
71 |
72 | 73 |
74 |
KNX-Frontend Version
75 |
${VERSION}
76 |
77 | 78 |
79 |
${this.knx.localize("info_connected_to_bus")}
80 |
81 | ${this.hass.localize(this.knx.info.connected ? "ui.common.yes" : "ui.common.no")} 82 |
83 |
84 | 85 |
86 |
${this.knx.localize("info_individual_address")}
87 |
${this.knx.info.current_address}
88 |
89 | 90 |
91 | ${this.knx.localize("info_issue_tracker")} 92 | xknx/knx-integration 93 |
94 | 95 |
96 | ${this.knx.localize("info_my_knx")} 97 | my.knx.org 98 |
99 |
100 |
`; 101 | } 102 | 103 | private _renderProjectDataCard(projectInfo: KNXProjectInfo) { 104 | return html` 105 | 106 |
107 |
108 | ${this.knx.localize("info_project_data_header")} 109 |
110 |
111 |
${this.knx.localize("info_project_data_name")}
112 |
${projectInfo.name}
113 |
114 |
115 |
${this.knx.localize("info_project_data_last_modified")}
116 |
${new Date(projectInfo.last_modified).toUTCString()}
117 |
118 |
119 |
${this.knx.localize("info_project_data_tool_version")}
120 |
${projectInfo.tool_version}
121 |
122 |
123 |
${this.knx.localize("info_project_data_xknxproject_version")}
124 |
${projectInfo.xknxproject_version}
125 |
126 |
127 | 132 | ${this.knx.localize("info_project_delete")} 133 | 134 |
135 |
136 | 137 |
138 | `; 139 | } 140 | 141 | private _renderProjectUploadCard() { 142 | return html` 143 |
144 |
${this.knx.localize("info_project_file_header")}
145 |
${this.knx.localize("info_project_upload_description")}
146 |
147 | 156 |
157 |
158 | 166 | 167 |
168 |
169 | ${this.hass.localize("ui.common.submit")} 175 |
176 |
177 |
`; 178 | } 179 | 180 | private _filePicked(ev) { 181 | this._projectFile = ev.detail.files[0]; 182 | } 183 | 184 | private _passwordChanged(ev) { 185 | this._projectPassword = ev.detail.value; 186 | } 187 | 188 | private async _uploadFile(_ev) { 189 | const file = this._projectFile; 190 | if (typeof file === "undefined") { 191 | return; 192 | } 193 | 194 | let error: Error | undefined; 195 | this._uploading = true; 196 | try { 197 | const project_file_id = await uploadFile(this.hass, file); 198 | await processProjectFile(this.hass, project_file_id, this._projectPassword || ""); 199 | } catch (err: any) { 200 | error = err; 201 | showAlertDialog(this, { 202 | title: "Upload failed", 203 | text: extractApiErrorMessage(err), 204 | }); 205 | } finally { 206 | if (!error) { 207 | this._projectFile = undefined; 208 | this._projectPassword = undefined; 209 | } 210 | this._uploading = false; 211 | fireEvent(this, "knx-reload"); 212 | } 213 | } 214 | 215 | private async _removeProject(_ev) { 216 | const confirmed = await showConfirmationDialog(this, { 217 | text: this.knx.localize("info_project_delete"), 218 | }); 219 | if (!confirmed) { 220 | logger.debug("User cancelled deletion"); 221 | return; 222 | } 223 | 224 | try { 225 | await removeProjectFile(this.hass); 226 | } catch (err: any) { 227 | showAlertDialog(this, { 228 | title: "Deletion failed", 229 | text: extractApiErrorMessage(err), 230 | }); 231 | } finally { 232 | fireEvent(this, "knx-reload"); 233 | } 234 | } 235 | 236 | static styles = css` 237 | .columns { 238 | display: flex; 239 | justify-content: center; 240 | } 241 | 242 | @media screen and (max-width: 1232px) { 243 | .columns { 244 | flex-direction: column; 245 | } 246 | 247 | .knx-button-row { 248 | margin-top: 20px; 249 | } 250 | 251 | .knx-info { 252 | margin-right: 8px; 253 | } 254 | } 255 | 256 | @media screen and (min-width: 1233px) { 257 | .knx-button-row { 258 | margin-top: auto; 259 | } 260 | 261 | .knx-info { 262 | width: 400px; 263 | } 264 | } 265 | 266 | .knx-info { 267 | margin-left: 8px; 268 | margin-top: 8px; 269 | } 270 | 271 | .knx-content { 272 | display: flex; 273 | flex-direction: column; 274 | height: 100%; 275 | box-sizing: border-box; 276 | } 277 | 278 | .knx-content-row { 279 | display: flex; 280 | flex-direction: row; 281 | justify-content: space-between; 282 | } 283 | 284 | .knx-content-row > div:nth-child(2) { 285 | margin-left: 1rem; 286 | } 287 | 288 | .knx-button-row { 289 | display: flex; 290 | flex-direction: row; 291 | vertical-align: bottom; 292 | padding-top: 16px; 293 | } 294 | 295 | .push-left { 296 | margin-right: auto; 297 | } 298 | 299 | .push-right { 300 | margin-left: auto; 301 | } 302 | 303 | .knx-warning { 304 | --mdc-theme-primary: var(--error-color); 305 | } 306 | 307 | .knx-project-description { 308 | margin-top: -8px; 309 | padding: 0px 16px 16px; 310 | } 311 | 312 | .knx-delete-project-button { 313 | position: absolute; 314 | bottom: 0; 315 | right: 0; 316 | } 317 | 318 | .knx-bug-report { 319 | margin-top: 20px; 320 | 321 | a { 322 | text-decoration: none; 323 | } 324 | } 325 | 326 | .header { 327 | color: var(--ha-card-header-color, --primary-text-color); 328 | font-family: var(--ha-card-header-font-family, inherit); 329 | font-size: var(--ha-card-header-font-size, 24px); 330 | letter-spacing: -0.012em; 331 | line-height: 48px; 332 | padding: -4px 16px 16px; 333 | display: inline-block; 334 | margin-block-start: 0px; 335 | margin-block-end: 4px; 336 | font-weight: normal; 337 | } 338 | 339 | ha-file-upload, 340 | ha-selector-text { 341 | width: 100%; 342 | margin-top: 8px; 343 | } 344 | `; 345 | } 346 | 347 | declare global { 348 | interface HTMLElementTagNameMap { 349 | "knx-info": KNXInfo; 350 | } 351 | } 352 | -------------------------------------------------------------------------------- /src/views/project_view.ts: -------------------------------------------------------------------------------- 1 | import { mdiFilterVariant, mdiPlus } from "@mdi/js"; 2 | import type { TemplateResult } from "lit"; 3 | import { LitElement, html, css, nothing } from "lit"; 4 | import { customElement, property, state } from "lit/decorators"; 5 | 6 | import memoize from "memoize-one"; 7 | 8 | import type { HASSDomEvent } from "@ha/common/dom/fire_event"; 9 | import { navigate } from "@ha/common/navigate"; 10 | import "@ha/layouts/hass-loading-screen"; 11 | import "@ha/layouts/hass-tabs-subpage"; 12 | import type { PageNavigation } from "@ha/layouts/hass-tabs-subpage"; 13 | import "@ha/components/ha-card"; 14 | import "@ha/components/ha-icon-button"; 15 | import "@ha/components/ha-icon-overflow-menu"; 16 | import "@ha/components/data-table/ha-data-table"; 17 | import type { DataTableColumnContainer } from "@ha/components/data-table/ha-data-table"; 18 | import type { IconOverflowMenuItem } from "@ha/components/ha-icon-overflow-menu"; 19 | import { relativeTime } from "@ha/common/datetime/relative_time"; 20 | 21 | import "../components/knx-project-tree-view"; 22 | 23 | import { compare } from "compare-versions"; 24 | 25 | import type { HomeAssistant, Route } from "@ha/types"; 26 | import type { KNX } from "../types/knx"; 27 | import type { GroupRangeSelectionChangedEvent } from "../components/knx-project-tree-view"; 28 | import { subscribeKnxTelegrams, getGroupTelegrams } from "../services/websocket.service"; 29 | import type { GroupAddress, TelegramDict } from "../types/websocket"; 30 | import { KNXLogger } from "../tools/knx-logger"; 31 | import { TelegramDictFormatter } from "../utils/format"; 32 | 33 | const logger = new KNXLogger("knx-project-view"); 34 | // Minimum XKNXProject Version needed which was used for parsing the ETS Project 35 | const MIN_XKNXPROJECT_VERSION = "3.3.0"; 36 | 37 | @customElement("knx-project-view") 38 | export class KNXProjectView extends LitElement { 39 | @property({ type: Object }) public hass!: HomeAssistant; 40 | 41 | @property({ attribute: false }) public knx!: KNX; 42 | 43 | @property({ type: Boolean, reflect: true }) public narrow!: boolean; 44 | 45 | @property({ type: Object }) public route?: Route; 46 | 47 | @property({ type: Array, reflect: false }) public tabs!: PageNavigation[]; 48 | 49 | @property({ type: Boolean, reflect: true, attribute: "range-selector-hidden" }) 50 | public rangeSelectorHidden = true; 51 | 52 | @state() private _visibleGroupAddresses: string[] = []; 53 | 54 | @state() private _groupRangeAvailable = false; 55 | 56 | @state() private _subscribed?: () => void; 57 | 58 | @state() private _lastTelegrams: Record = {}; 59 | 60 | public disconnectedCallback() { 61 | super.disconnectedCallback(); 62 | if (this._subscribed) { 63 | this._subscribed(); 64 | this._subscribed = undefined; 65 | } 66 | } 67 | 68 | protected async firstUpdated() { 69 | if (!this.knx.project) { 70 | this.knx.loadProject().then(() => { 71 | this._isGroupRangeAvailable(); 72 | this.requestUpdate(); 73 | }); 74 | } else { 75 | // project was already loaded 76 | this._isGroupRangeAvailable(); 77 | } 78 | 79 | getGroupTelegrams(this.hass) 80 | .then((groupTelegrams) => { 81 | this._lastTelegrams = groupTelegrams; 82 | }) 83 | .catch((err) => { 84 | logger.error("getGroupTelegrams", err); 85 | navigate("/knx/error", { replace: true, data: err }); 86 | }); 87 | this._subscribed = await subscribeKnxTelegrams(this.hass, (telegram) => { 88 | this.telegram_callback(telegram); 89 | }); 90 | } 91 | 92 | private _isGroupRangeAvailable() { 93 | const projectVersion = this.knx.project?.knxproject.info.xknxproject_version ?? "0.0.0"; 94 | logger.debug("project version: " + projectVersion); 95 | this._groupRangeAvailable = compare(projectVersion, MIN_XKNXPROJECT_VERSION, ">="); 96 | } 97 | 98 | protected telegram_callback(telegram: TelegramDict): void { 99 | this._lastTelegrams = { 100 | ...this._lastTelegrams, 101 | [telegram.destination]: telegram, 102 | }; 103 | } 104 | 105 | private _columns = memoize((_narrow, _language): DataTableColumnContainer => { 106 | const addressWidth = "100px"; 107 | const dptWidth = "82px"; 108 | const overflowMenuWidth = "72px"; 109 | 110 | return { 111 | address: { 112 | filterable: true, 113 | sortable: true, 114 | title: this.knx.localize("project_view_table_address"), 115 | flex: 1, 116 | minWidth: addressWidth, 117 | }, 118 | name: { 119 | filterable: true, 120 | sortable: true, 121 | title: this.knx.localize("project_view_table_name"), 122 | flex: 3, 123 | }, 124 | dpt: { 125 | sortable: true, 126 | filterable: true, 127 | title: this.knx.localize("project_view_table_dpt"), 128 | flex: 1, 129 | minWidth: dptWidth, 130 | template: (ga: GroupAddress) => 131 | ga.dpt 132 | ? html`${ga.dpt.main}${ga.dpt.sub ? "." + ga.dpt.sub.toString().padStart(3, "0") : ""} ` 135 | : "", 136 | }, 137 | lastValue: { 138 | filterable: true, 139 | title: this.knx.localize("project_view_table_last_value"), 140 | flex: 2, 141 | template: (ga: GroupAddress) => { 142 | const lastTelegram: TelegramDict | undefined = this._lastTelegrams[ga.address]; 143 | if (!lastTelegram) return ""; 144 | const payload = TelegramDictFormatter.payload(lastTelegram); 145 | if (lastTelegram.value == null) return html`${payload}`; 146 | return html`
147 | ${TelegramDictFormatter.valueWithUnit(this._lastTelegrams[ga.address])} 148 |
`; 149 | }, 150 | }, 151 | updated: { 152 | title: this.knx.localize("project_view_table_updated"), 153 | flex: 1, 154 | showNarrow: false, 155 | template: (ga: GroupAddress) => { 156 | const lastTelegram: TelegramDict | undefined = this._lastTelegrams[ga.address]; 157 | if (!lastTelegram) return ""; 158 | const tooltip = `${TelegramDictFormatter.dateWithMilliseconds(lastTelegram)}\n\n${lastTelegram.source} ${lastTelegram.source_name}`; 159 | return html`
160 | ${relativeTime(new Date(lastTelegram.timestamp), this.hass.locale)} 161 |
`; 162 | }, 163 | }, 164 | actions: { 165 | title: "", 166 | minWidth: overflowMenuWidth, 167 | type: "overflow-menu", 168 | template: (ga: GroupAddress) => this._groupAddressMenu(ga), 169 | }, 170 | }; 171 | }); 172 | 173 | private _groupAddressMenu(groupAddress: GroupAddress): TemplateResult | typeof nothing { 174 | const items: IconOverflowMenuItem[] = []; 175 | if (groupAddress.dpt?.main === 1) { 176 | items.push({ 177 | path: mdiPlus, 178 | label: "Create binary sensor", 179 | action: () => { 180 | navigate( 181 | "/knx/entities/create/binary_sensor?knx.ga_sensor.state=" + groupAddress.address, 182 | ); 183 | }, 184 | }); 185 | } 186 | 187 | return items.length 188 | ? html` 189 | 190 | ` 191 | : nothing; 192 | } 193 | 194 | private _getRows(visibleGroupAddresses: string[]): GroupAddress[] { 195 | if (!visibleGroupAddresses.length) 196 | // if none is set, default to show all 197 | return Object.values(this.knx.project!.knxproject.group_addresses); 198 | 199 | return Object.entries(this.knx.project!.knxproject.group_addresses).reduce( 200 | (result, [key, groupAddress]) => { 201 | if (visibleGroupAddresses.includes(key)) { 202 | result.push(groupAddress); 203 | } 204 | return result; 205 | }, 206 | [] as GroupAddress[], 207 | ); 208 | } 209 | 210 | private _visibleAddressesChanged(ev: HASSDomEvent) { 211 | this._visibleGroupAddresses = ev.detail.groupAddresses; 212 | } 213 | 214 | protected render(): TemplateResult { 215 | if (!this.hass || !this.knx.project) { 216 | return html` `; 217 | } 218 | 219 | const filtered = this._getRows(this._visibleGroupAddresses); 220 | 221 | return html` 222 | 229 | ${this.knx.project.project_loaded 230 | ? html`${this.narrow && this._groupRangeAvailable 231 | ? html`` 237 | : nothing} 238 |
239 | ${this._groupRangeAvailable 240 | ? html` 241 | 245 | ` 246 | : nothing} 247 | 256 |
` 257 | : html` 258 |
259 |

${this.knx.localize("project_view_upload")}

260 |
261 |
`} 262 |
263 | `; 264 | } 265 | 266 | private _toggleRangeSelector() { 267 | this.rangeSelectorHidden = !this.rangeSelectorHidden; 268 | } 269 | 270 | static styles = css` 271 | hass-loading-screen { 272 | --app-header-background-color: var(--sidebar-background-color); 273 | --app-header-text-color: var(--sidebar-text-color); 274 | } 275 | .sections { 276 | display: flex; 277 | flex-direction: row; 278 | height: 100%; 279 | } 280 | 281 | :host([narrow]) knx-project-tree-view { 282 | position: absolute; 283 | max-width: calc(100% - 60px); /* 100% -> max 871px before not narrow */ 284 | z-index: 1; 285 | right: 0; 286 | transition: 0.5s; 287 | border-left: 1px solid var(--divider-color); 288 | } 289 | 290 | :host([narrow][range-selector-hidden]) knx-project-tree-view { 291 | width: 0; 292 | } 293 | 294 | :host(:not([narrow])) knx-project-tree-view { 295 | max-width: 255px; /* min 616px - 816px for tree-view + ga-table (depending on side menu) */ 296 | } 297 | 298 | .ga-table { 299 | flex: 1; 300 | } 301 | `; 302 | } 303 | 304 | declare global { 305 | interface HTMLElementTagNameMap { 306 | "knx-project-view": KNXProjectView; 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2021", 4 | "lib": ["ES2021", "DOM", "DOM.Iterable", "WebWorker"], 5 | "experimentalDecorators": true, 6 | "useDefineForClassFields": false, 7 | // Modules 8 | "module": "ESNext", 9 | "moduleResolution": "node", 10 | "resolveJsonModule": true, 11 | // Babel handles transpiling and no need for declaration files 12 | "noEmit": true, 13 | // Caching 14 | "incremental": true, 15 | "tsBuildInfoFile": "node_modules/.cache/typescript/.tsbuildinfo", 16 | // Type checking options 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noImplicitReturns": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "strict": true, 22 | "noImplicitAny": false, 23 | // Do not check type declaration files 24 | "skipLibCheck": true, 25 | // Interop with CommonJS and other tools 26 | "esModuleInterop": true, 27 | "isolatedModules": true, 28 | "baseUrl": "src", 29 | "paths": { 30 | "@ha/*": ["../homeassistant-frontend/src/*"], 31 | "lit/static-html": ["./node_modules/lit/static-html.js"], 32 | "lit/decorators": ["./node_modules/lit/decorators.js"], 33 | "lit/directive": ["./node_modules/lit/directive.js"], 34 | "lit/directives/until": ["./node_modules/lit/directives/until.js"], 35 | "lit/directives/class-map": [ 36 | "./node_modules/lit/directives/class-map.js" 37 | ], 38 | "lit/directives/style-map": [ 39 | "./node_modules/lit/directives/style-map.js" 40 | ], 41 | "lit/directives/if-defined": [ 42 | "./node_modules/lit/directives/if-defined.js" 43 | ], 44 | "lit/directives/guard": ["./node_modules/lit/directives/guard.js"], 45 | "lit/directives/cache": ["./node_modules/lit/directives/cache.js"], 46 | "lit/directives/repeat": ["./node_modules/lit/directives/repeat.js"], 47 | "lit/directives/live": ["./node_modules/lit/directives/live.js"], 48 | "lit/directives/keyed": ["./node_modules/lit/directives/keyed.js"], 49 | "lit/polyfill-support": ["./node_modules/lit/polyfill-support.js"], 50 | "@lit-labs/virtualizer/layouts/grid": [ 51 | "./node_modules/@lit-labs/virtualizer/layouts/grid.js" 52 | ], 53 | "@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver": [ 54 | "./node_modules/@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver.js" 55 | ], 56 | "@lit-labs/observers/resize-controller": [ 57 | "./node_modules/@lit-labs/observers/resize-controller.js" 58 | ] 59 | }, 60 | "plugins": [ 61 | { 62 | "name": "ts-lit-plugin", 63 | "strict": true, 64 | "rules": { 65 | "no-unknown-tag-name": "error", 66 | // "no-missing-import": "error", // not supported with paths (@ha/*) https://github.com/runem/lit-analyzer/issues/293 67 | "no-missing-element-type-definition": "error", 68 | // Binding names 69 | "no-unknown-attribute": "off", 70 | "no-legacy-attribute": "error", 71 | // Binding types 72 | "no-incompatible-type-binding": "warning", 73 | // LitElement 74 | "no-property-visibility-mismatch": "error", 75 | // CSS 76 | "no-invalid-css": "off" // warning does not work 77 | }, 78 | "globalTags": ["google-cast-launcher"] 79 | } 80 | ] 81 | } 82 | } --------------------------------------------------------------------------------