├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .github └── workflows │ ├── ci.yaml │ └── release.yaml ├── .gitignore ├── .prettierrc.json ├── .vscode └── tasks.json ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── packages ├── captive-portal │ ├── README.md │ ├── index.html │ ├── package.json │ ├── public │ │ └── config.json │ ├── src │ │ ├── icon │ │ │ ├── lock.svg │ │ │ └── wifi.svg │ │ ├── main.ts │ │ └── stylesheet.css │ └── vite.config.ts ├── v1 │ ├── package.json │ └── src │ │ ├── webserver-v1.css │ │ ├── webserver-v1.js │ │ ├── webserver-v1.min.css │ │ └── webserver-v1.min.js ├── v2 │ ├── index.html │ ├── package.json │ ├── public │ │ ├── home.svg │ │ └── logo.svg │ ├── src │ │ ├── css │ │ │ ├── button.ts │ │ │ ├── main.css │ │ │ └── reset.ts │ │ ├── esp-app.ts │ │ ├── esp-entity-table.ts │ │ ├── esp-log.ts │ │ ├── esp-logo.ts │ │ ├── esp-switch.ts │ │ └── main.ts │ └── vite.config.ts └── v3 │ ├── index.html │ ├── package.json │ ├── public │ └── logo.svg │ ├── src │ ├── css │ │ ├── app.ts │ │ ├── button.ts │ │ ├── esp-entity-table.ts │ │ ├── input.ts │ │ ├── reset.ts │ │ └── tab.ts │ ├── esp-app.ts │ ├── esp-entity-chart.ts │ ├── esp-entity-table.ts │ ├── esp-log.ts │ ├── esp-logo.ts │ ├── esp-range-slider.ts │ ├── esp-switch.ts │ ├── main.css │ └── main.ts │ └── vite.config.ts ├── scripts └── make_header.sh └── tsconfig.json /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.195.0/containers/javascript-node/.devcontainer/base.Dockerfile 2 | FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:16-bullseye 3 | 4 | ENV \ 5 | DEBIAN_FRONTEND=noninteractive \ 6 | DEVCONTAINER=true \ 7 | PATH=$PATH:./node_modules/.bin 8 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ESPHome Webserver", 3 | "build": { 4 | "dockerfile": "Dockerfile", 5 | "context": ".." 6 | }, 7 | "appPort": "5001:5001", 8 | "postCreateCommand": "npm install", 9 | "features": { 10 | "ghcr.io/devcontainers/features/github-cli": {} 11 | }, 12 | "customizations": { 13 | "vscode": { 14 | "extensions": [ 15 | "github.vscode-pull-request-github", 16 | "dbaeumer.vscode-eslint", 17 | "esbenp.prettier-vscode", 18 | "bierner.lit-html", 19 | "runem.lit-plugin" 20 | ], 21 | "settings": { 22 | "files.eol": "\n", 23 | "editor.tabSize": 2, 24 | "editor.formatOnPaste": false, 25 | "editor.formatOnSave": true, 26 | "editor.formatOnType": true, 27 | "[typescript]": { 28 | "editor.defaultFormatter": "esbenp.prettier-vscode" 29 | }, 30 | "[javascript]": { 31 | "editor.defaultFormatter": "esbenp.prettier-vscode" 32 | }, 33 | "files.trimTrailingWhitespace": true 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - dev 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | name: Build ${{ matrix.name }} 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | include: 17 | - name: Captive Portal 18 | directory: captive-portal 19 | - name: Webserver v2 20 | directory: v2 21 | - name: Webserver v3 22 | directory: v3 23 | steps: 24 | - name: Clone the repo 25 | uses: actions/checkout@v4.1.1 26 | - name: Set up Node.JS 27 | uses: actions/setup-node@v4.0.2 28 | - name: Install dependencies 29 | run: npm install 30 | - name: Build ${{ matrix.name }} 31 | run: npm run build 32 | working-directory: packages/${{ matrix.directory }} 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | name: Build ${{ matrix.name }} 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | include: 15 | - name: Captive Portal 16 | directory: captive-portal 17 | - name: Webserver v2 18 | directory: v2 19 | - name: Webserver v3 20 | directory: v3 21 | steps: 22 | - name: Clone the repo 23 | uses: actions/checkout@v4.1.1 24 | 25 | - name: Set up Node.JS 26 | uses: actions/setup-node@v4.0.2 27 | 28 | - name: Install dependencies 29 | run: npm install 30 | 31 | - name: Build ${{ matrix.name }} 32 | run: npm run build 33 | working-directory: packages/${{ matrix.directory }} 34 | 35 | - uses: actions/upload-artifact@v4.3.1 36 | with: 37 | name: ${{ matrix.name }} 38 | path: _static/**/*.h 39 | 40 | release: 41 | name: Tag and Release 42 | runs-on: ubuntu-latest 43 | needs: build 44 | outputs: 45 | tag: ${{ steps.create_tag.outputs.tag }} 46 | steps: 47 | # Checkout repo, create new git tag, and git release with artifacts 48 | - name: Checkout the repo 49 | uses: actions/checkout@v4.1.1 50 | 51 | - name: Create a new tag 52 | id: create_tag 53 | run: echo tag=$(date +'%Y%m%d-%H%M%S') >> $GITHUB_OUTPUT 54 | 55 | - name: Download Artifacts 56 | uses: actions/download-artifact@v4.1.4 57 | with: 58 | path: headers 59 | merge-multiple: true 60 | 61 | - name: List files 62 | run: ls -R headers 63 | 64 | - name: Create a release 65 | id: create_release 66 | uses: softprops/action-gh-release@v2.0.4 67 | with: 68 | tag_name: ${{ steps.create_tag.outputs.tag }} 69 | name: Release ${{ steps.create_tag.outputs.tag }} 70 | files: headers/**/*.h 71 | generate_release_notes: true 72 | 73 | esphome-pr: 74 | name: Make PR into ESPHome repo 75 | runs-on: ubuntu-latest 76 | environment: esphome 77 | needs: 78 | - release 79 | steps: 80 | - name: Checkout esphome repo 81 | uses: actions/checkout@v4.1.1 82 | with: 83 | repository: esphome/esphome 84 | ref: dev 85 | 86 | - name: Download Artifacts 87 | uses: actions/download-artifact@v4.1.4 88 | with: 89 | path: /tmp/headers 90 | merge-multiple: true 91 | 92 | - name: Move headers into palce 93 | run: |- 94 | mv /tmp/headers/captive_portal/captive_index.h esphome/components/captive_portal/captive_index.h 95 | mv /tmp/headers/v2/server_index_v2.h esphome/components/web_server/server_index_v2.h 96 | mv /tmp/headers/v3/server_index_v3.h esphome/components/web_server/server_index_v3.h 97 | 98 | - name: PR Changes 99 | uses: peter-evans/create-pull-request@v6.0.4 100 | with: 101 | commit-message: "Update webserver local assets to ${{ needs.release.outputs.tag }}" 102 | committer: esphomebot <68923041+esphomebot@users.noreply.github.com> 103 | author: esphomebot <68923041+esphomebot@users.noreply.github.com> 104 | branch: sync/webserver-assets 105 | delete-branch: true 106 | title: "Update webserver local assets to ${{ needs.release.outputs.tag }}" 107 | body-path: .github/PULL_REQUEST_TEMPLATE.md 108 | token: ${{ secrets.ESPHOME_WEBSERVER_SYNC_TOKEN }} 109 | assignees: "@esphome/core" 110 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Build results 45 | _static/ 46 | 47 | # TypeScript v1 declaration files 48 | typings/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Microbundle cache 60 | .rpt2_cache/ 61 | .rts2_cache_cjs/ 62 | .rts2_cache_es/ 63 | .rts2_cache_umd/ 64 | 65 | # Optional REPL history 66 | .node_repl_history 67 | 68 | # Output of 'npm pack' 69 | *.tgz 70 | 71 | # Yarn Integrity file 72 | .yarn-integrity 73 | 74 | # dotenv environment variables file 75 | .env 76 | .env.test 77 | 78 | # parcel-bundler cache (https://parceljs.org/) 79 | .cache 80 | 81 | # Next.js build output 82 | .next 83 | 84 | # Nuxt.js build / generate output 85 | .nuxt 86 | dist 87 | 88 | # Gatsby files 89 | .cache/ 90 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 91 | # https://nextjs.org/blog/next-9-1#public-directory-support 92 | # public 93 | 94 | # vuepress build output 95 | .vuepress/dist 96 | 97 | # Serverless directories 98 | .serverless/ 99 | 100 | # FuseBox cache 101 | .fusebox/ 102 | 103 | # DynamoDB Local files 104 | .dynamodb/ 105 | 106 | # TernJS port file 107 | .tern-port 108 | 109 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "html.format.wrapAttributes": "auto", 3 | "html.format.wrapLineLength": 0, 4 | "printWidth": 80 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Start Dev Server", 6 | "type": "shell", 7 | "command": "npm start", 8 | "problemMatcher": [] 9 | }, 10 | { 11 | "label": "Build", 12 | "type": "shell", 13 | "command": "npm run build", 14 | "problemMatcher": [] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 wilberforce 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # esphome-webserver 2 | A Lit Element web component htm webserver for esphome devices. 3 | 4 | ### Features 5 | 6 | - 30 sec heartbeat showing node connection is active 7 | - Built with Lit Element web components 8 | - Completely standalone - no other external dependencies 9K compressed 9 | - Light and Dark themes 10 | - Primary theme - currently light blue - can be overridden 11 | - Embedded ESP home logo svg 12 | - Entities are discovered and display 13 | - No css fetch - index page fetches one js file 14 | 15 | dark scheme desktop: 16 | ==================== 17 | ![web_server-v2](https://user-images.githubusercontent.com/5050824/141174356-789cc160-46a1-43fc-9a86-ed5a764c35d7.png) 18 | 19 | Light scheme on mobile: 20 | ======================= 21 | ![image](https://user-images.githubusercontent.com/5050824/141175240-95b5b74e-d8c8-48bc-9d6d-053ebeaf8910.png) 22 | 23 | ### Near future: 24 | 25 | - [ ] Support for compressed files in flash for Standalone no internet nodes 26 | - [ ] Add Climate 27 | - [x] Add Select drop list 28 | - [ ] Add Number editing 29 | - [ ] Potentially use an optional card layout instead of a table 30 | 31 | ## Example entry for `config.yaml`: 32 | 33 | ```yaml 34 | web_server: 35 | port: 80 36 | css_url: "" 37 | js_url: https://esphome.io/_static/v2/www.js 38 | version: 2 39 | ``` 40 | 41 | development 42 | =========== 43 | 44 | ``` 45 | git clone https://github.com/esphome/esphome-webserver.git 46 | cd esphome-webserver 47 | npm install 48 | ``` 49 | 50 | Build and deploy all packages from the root directory: 51 | ```` 52 | npm run build 53 | ```` 54 | 55 | ### Work with specific packages 56 | Starts a dev server on http://localhost:3000 57 | ``` 58 | cd packages/v2 59 | npm run start 60 | ``` 61 | 62 | proxy 63 | ====== 64 | Events from a real device can be proxied for development by using the `PROXY_TARGET` environment variable. 65 | 66 | ``` 67 | PROXY_TARGET=http://nodemcu.local npm run build 68 | # and/or 69 | PROXY_TARGET=http://nodemcu.local npm run serve 70 | ``` 71 | 72 | Alternatively, update this line in `packages/[version]/vite.config.ts` to point to your real device. 73 | ```js 74 | const proxy_target = process.env.PROXY_TARGET || "http://nodemcu.local"; 75 | ``` 76 | 77 | The json api will POST to the real device and the events are proxied 78 | 79 | build 80 | ===== 81 | ```js 82 | cd packages/v2 83 | npm run build 84 | ``` 85 | The build files are copied to _static/v2 usually for deployment to https://oi.esphome.io/v2 or your /local/www Home Assistant folder 86 | 87 | If you customise, you can deploy to your local Home Assistant /local/www/_static/v2 and use: 88 | 89 | ```yaml 90 | web_server: 91 | port: 80 92 | version: 2 93 | js_url: http://homeassistant.local:8123/local/_static/v2/www.js 94 | ``` 95 | 96 | To use a specific version of a CDN hosted device dashboard, you can use the following override as an example: 97 | ```yaml 98 | web_server: 99 | port: 80 100 | version: 3 101 | js_url: https://oi.esphome.io/v3/www.js 102 | ``` 103 | 104 | serve 105 | ===== 106 | ```js 107 | cd packages/v2 108 | npm run serve 109 | ``` 110 | Starts a production test server on http://localhost:5001 111 | Events and the json api are proxied. 112 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "esphome-webserver", 3 | "version": "3.0.0", 4 | "license": "MIT", 5 | "workspaces": [ 6 | "packages/*" 7 | ], 8 | "scripts": { 9 | "build": "npm run build:packages && npm run deploy", 10 | "build:packages": "npm run build --workspaces --if-present", 11 | "deploy": "npm run deploy --workspaces --if-present" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/captive-portal/README.md: -------------------------------------------------------------------------------- 1 | # captive-portal 2 | Source code to build esphome captive portal. Output is `captive_index.h` file to be included by the Captive Portal Component https://esphome.io/components/captive_portal.html 3 | 4 | ### Features 5 | 6 | - All assets (css, svg and js) are inlined and served from index.html 7 | - index.html is gzipped, and stored in flash compressed saving ~1K of flash memory from previous version 8 | - ssid scan result is returned via `/config.json` api request 9 | 10 | 11 | development 12 | =========== 13 | 14 | ``` 15 | git clone https://github.com/esphome/esphome-webserver.git 16 | cd captive-portal 17 | pnpm install 18 | ``` 19 | 20 | `npm run start` 21 | Starts a dev server on http://localhost:3000 22 | 23 | build 24 | ===== 25 | `npm run build` 26 | The build files are copied to `dist` folder. `captive_index.h` is built to be deployed to https://github.com/esphome/esphome/tree/dev/esphome/components/captive_portal 27 | 28 | 29 | serve 30 | ===== 31 | `npm run server` 32 | Starts a production test server on http://localhost:5001 33 | -------------------------------------------------------------------------------- /packages/captive-portal/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 |

MAC Address

13 |

WiFi Networks

14 | 18 | 19 | 20 | 21 |

WiFi Settings

22 |
23 |
24 |
25 |
26 | 27 |
28 |
29 |
30 |
31 | 32 |

OTA Update

33 |
34 | 35 | 36 |
37 |
38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /packages/captive-portal/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@esphome-webserver/captive-portal", 3 | "version": "2.0.0", 4 | "main": "main.ts", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "npm run dev", 8 | "dev": "vite", 9 | "build": "vite build && npm run deploy", 10 | "serve": "vite preview", 11 | "deploy": "bash -c '../../scripts/make_header.sh ../../_static/captive_portal captive_index.h captive_portal'" 12 | }, 13 | "dependencies": { 14 | "rollup-plugin-generate-html-template": "^1.7.0", 15 | "rollup-plugin-gzip": "^2.5.0", 16 | "rollup-plugin-minify-html-template-literals": "^1.2.0", 17 | "vite-plugin-html": "^2.1.1" 18 | }, 19 | "devDependencies": { 20 | "@rollup/plugin-node-resolve": "^13.0.6", 21 | "@types/node": "^15.12.1", 22 | "typescript": "^4.1.3", 23 | "vite": "^2.6.14", 24 | "vite-plugin-singlefile": "^0.5.1" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/captive-portal/public/config.json: -------------------------------------------------------------------------------- 1 | {"name":"My ESPhome", 2 | "aps":[ 3 | {} 4 | ,{"ssid":"Hermione","rssi":-50,"lock":0} 5 | ,{"ssid":"Neville","rssi":-65,"lock":1} 6 | ,{"ssid":"Gandalf the Grey","rssi":-85,"lock":1} 7 | ,{"ssid":"Hagrid","rssi":-95,"lock":0} 8 | ] 9 | } -------------------------------------------------------------------------------- /packages/captive-portal/src/icon/lock.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/captive-portal/src/icon/wifi.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/captive-portal/src/main.ts: -------------------------------------------------------------------------------- 1 | if (document.location.search === "?save") document.getElementsByTagName("aside")[0].style.display = "block"; 2 | function wifi(dBm: number) { 3 | let quality: number = Math.max(Math.min(2 * (dBm + 100), 100), 0) / 100.0; 4 | return svg(` 5 | `) 6 | } 7 | function svg(el:String) { 8 | return html([`${el}`]) 9 | } 10 | function lock(show: boolean) { 11 | return show 12 | ? svg(``) 16 | : "" 17 | } 18 | function html(h: String[]) { 19 | return h.join(""); 20 | } 21 | fetch("/config.json").then(function (response) { 22 | response.json().then(function (config) { 23 | document.title = config.name; 24 | document.body.getElementsByTagName("h1")[0].innerText = "MAC Address: " + config.mac 25 | document.body.getElementsByTagName("h1")[1].innerText = "WiFi Networks: " + config.name 26 | let result = config.aps.slice(1).map(function (ap) { 27 | return `
29 | 30 | ${wifi(ap.rssi)} 31 | ${ap.ssid} 32 | 33 | ${lock(ap.lock)} 34 |
` 35 | }) 36 | document.querySelector("#net").innerHTML = html(result) 37 | document.querySelector("link[rel~='icon']").href = `data:image/svg+xml,${wifi(-65)}`; 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /packages/captive-portal/src/stylesheet.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: inherit; 3 | } 4 | 5 | div, 6 | input { 7 | padding: 5px; 8 | font-size: 1em; 9 | } 10 | 11 | input { 12 | width: 95%; 13 | } 14 | 15 | body { 16 | text-align: center; 17 | font-family: sans-serif; 18 | } 19 | 20 | button { 21 | border: 0; 22 | border-radius: 0.3rem; 23 | background-color: #1fa3ec; 24 | color: #fff; 25 | line-height: 2.4rem; 26 | font-size: 1.2rem; 27 | width: 100%; 28 | padding: 0; 29 | cursor: pointer; 30 | } 31 | 32 | main { 33 | text-align: left; 34 | display: inline-block; 35 | min-width: 260px; 36 | } 37 | 38 | .network { 39 | display: flex; 40 | justify-content: space-between; 41 | align-items: center; 42 | } 43 | 44 | .network-left { 45 | display: flex; 46 | align-items: center; 47 | } 48 | 49 | .network-ssid { 50 | margin-bottom: -7px; 51 | margin-left: 10px; 52 | } 53 | 54 | aside { 55 | border: 1px solid; 56 | margin: 10px 0px; 57 | padding: 15px 10px; 58 | color: #4f8a10; 59 | background-color: #dff2bf; 60 | display: none 61 | } 62 | 63 | i { 64 | width: 24px; 65 | height: 24px; 66 | } 67 | 68 | i.lock { 69 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 17a2 2 0 0 0 2-2 2 2 0 0 0-2-2 2 2 0 0 0-2 2 2 2 0 0 0 2 2m6-9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2h1V6a5 5 0 0 1 5-5 5 5 0 0 1 5 5v2h1m-6-5a3 3 0 0 0-3 3v2h6V6a3 3 0 0 0-3-3z'/%3E%3C/svg%3E"); 70 | } 71 | 72 | i.sig1 { 73 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' %3E%3Cpath d='M12 3A18.9 18.9 0 0 0 .38 7C4.41 12.06 7.89 16.37 12 21.5L23.65 7C20.32 4.41 16.22 3 12 3m0 2c3.07 0 6.09.86 8.71 2.45l-5.1 6.36a8.43 8.43 0 0 0-7.22-.01L3.27 7.44A16.94 16.94 0 0 1 12 5z'/%3E%3C/svg%3E"); 74 | } 75 | i.sig2 { 76 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' %3E%3Cpath d='M12 3A18.9 18.9 0 0 0 .38 7C4.41 12.06 7.89 16.37 12 21.5L23.65 7C20.32 4.41 16.22 3 12 3m0 2c3.07 0 6.09.86 8.71 2.45l-3.21 3.98a11.32 11.32 0 0 0-11 0L3.27 7.44A16.94 16.94 0 0 1 12 5z'/%3E%3C/svg%3E"); 77 | } 78 | i.sig3 { 79 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' %3E%3Cpath d='M12 3A18.9 18.9 0 0 0 .38 7C4.41 12.06 7.89 16.37 12 21.5L23.65 7C20.32 4.41 16.22 3 12 3m0 2c3.07 0 6.09.86 8.71 2.45l-1.94 2.43A13.6 13.6 0 0 0 12 8C9 8 6.68 9 5.21 9.84l-1.94-2.4A16.94 16.94 0 0 1 12 5z'/%3E%3C/svg%3E"); 80 | } 81 | i.sig4 { 82 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' %3E%3Cpath d='M12 3A18.9 18.9 0 0 0 .38 7C4.41 12.06 7.89 16.37 12 21.5L23.65 7C20.32 4.41 16.22 3 12 3z'/%3E%3C/svg%3E"); 83 | } 84 | 85 | i.sig { 86 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' %3E%3Cpath d='M12 3A18.9 18.9 0 0 0 .38 7C4.41 12.06 7.89 16.37 12 21.5L23.65 7C20.32 4.41 16.22 3 12 3m0 2c3.07 0 6.09.86 8.71 2.45l-5.1 6.36a8.43 8.43 0 0 0-7.22-.01L3.27 7.44A16.94 16.94 0 0 1 12 5z'/%3E%3C/svg%3E"); 87 | } 88 | -------------------------------------------------------------------------------- /packages/captive-portal/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import gzipPlugin from "rollup-plugin-gzip"; 3 | import { viteSingleFile } from "vite-plugin-singlefile"; 4 | 5 | import minifyHTML from "rollup-plugin-minify-html-template-literals"; 6 | import { minifyHtml as ViteMinifyHtml } from "vite-plugin-html"; 7 | 8 | export default defineConfig({ 9 | clearScreen: false, 10 | plugins: [ 11 | viteSingleFile(), 12 | { ...minifyHTML(), enforce: "pre", apply: "build" }, 13 | ViteMinifyHtml(), 14 | { 15 | ...gzipPlugin({ filter: /\.(html)$/ }), 16 | enforce: "post", 17 | apply: "build", 18 | }, 19 | ], 20 | css: { 21 | postcss: {}, 22 | }, 23 | build: { 24 | brotliSize: false, 25 | cssCodeSplit: false, 26 | outDir: "../../_static/captive_portal", 27 | assetsInlineLimit: 100000000, 28 | polyfillModulePreload: false, 29 | }, 30 | server: { 31 | open: "/", // auto open browser 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /packages/v1/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@esphome-webserver/v1", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "build": "mkdir -p ../../_static/v1 && cp ./src/* ../../_static/v1/" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/v1/src/webserver-v1.css: -------------------------------------------------------------------------------- 1 | /* Based off of https://github.com/sindresorhus/github-markdown-css */ 2 | 3 | .markdown-body { 4 | -ms-text-size-adjust: 100%; 5 | -webkit-text-size-adjust: 100%; 6 | line-height: 1.5; 7 | color: #24292e; 8 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 9 | font-size: 16px; 10 | word-wrap: break-word; 11 | } 12 | 13 | .markdown-body a { 14 | background-color: transparent; 15 | } 16 | 17 | .markdown-body a:active, 18 | .markdown-body a:hover { 19 | outline-width: 0; 20 | } 21 | 22 | .markdown-body strong { 23 | font-weight: bolder; 24 | } 25 | 26 | .markdown-body h1 { 27 | font-size: 2em; 28 | margin: 0.67em 0; 29 | } 30 | 31 | .markdown-body img { 32 | border-style: none; 33 | } 34 | 35 | .markdown-body pre { 36 | font-family: monospace, monospace; 37 | font-size: 1em; 38 | } 39 | 40 | .markdown-body hr { 41 | box-sizing: content-box; 42 | height: 0; 43 | overflow: visible; 44 | } 45 | 46 | .markdown-body input { 47 | font: inherit; 48 | margin: 0; 49 | } 50 | 51 | .markdown-body input { 52 | overflow: visible; 53 | } 54 | 55 | .markdown-body [type="checkbox"] { 56 | box-sizing: border-box; 57 | padding: 0; 58 | } 59 | 60 | .markdown-body * { 61 | box-sizing: border-box; 62 | } 63 | 64 | .markdown-body input { 65 | font-family: inherit; 66 | font-size: inherit; 67 | line-height: inherit; 68 | } 69 | 70 | .markdown-body a { 71 | color: #0366d6; 72 | text-decoration: none; 73 | } 74 | 75 | .markdown-body a:hover { 76 | text-decoration: underline; 77 | } 78 | 79 | .markdown-body strong { 80 | font-weight: 600; 81 | } 82 | 83 | .markdown-body hr { 84 | height: 0; 85 | margin: 15px 0; 86 | overflow: hidden; 87 | background: transparent; 88 | border: 0; 89 | border-bottom: 1px solid #dfe2e5; 90 | } 91 | 92 | .markdown-body hr::before { 93 | display: table; 94 | content: ""; 95 | } 96 | 97 | .markdown-body hr::after { 98 | display: table; 99 | clear: both; 100 | content: ""; 101 | } 102 | 103 | .markdown-body table { 104 | border-spacing: 0; 105 | border-collapse: collapse; 106 | } 107 | 108 | .markdown-body td, 109 | .markdown-body th { 110 | padding: 0; 111 | } 112 | 113 | .markdown-body h1, 114 | .markdown-body h2, 115 | .markdown-body h3, 116 | .markdown-body h4, 117 | .markdown-body h5, 118 | .markdown-body h6 { 119 | margin-top: 0; 120 | margin-bottom: 0; 121 | } 122 | 123 | .markdown-body h1 { 124 | font-size: 32px; 125 | font-weight: 600; 126 | } 127 | 128 | .markdown-body h2 { 129 | font-size: 24px; 130 | font-weight: 600; 131 | } 132 | 133 | .markdown-body h3 { 134 | font-size: 20px; 135 | font-weight: 600; 136 | } 137 | 138 | .markdown-body h4 { 139 | font-size: 16px; 140 | font-weight: 600; 141 | } 142 | 143 | .markdown-body h5 { 144 | font-size: 14px; 145 | font-weight: 600; 146 | } 147 | 148 | .markdown-body h6 { 149 | font-size: 12px; 150 | font-weight: 600; 151 | } 152 | 153 | .markdown-body p { 154 | margin-top: 0; 155 | margin-bottom: 10px; 156 | } 157 | 158 | .markdown-body blockquote { 159 | margin: 0; 160 | } 161 | 162 | .markdown-body ul, 163 | .markdown-body ol { 164 | padding-left: 0; 165 | margin-top: 0; 166 | margin-bottom: 0; 167 | } 168 | 169 | .markdown-body ol ol, 170 | .markdown-body ul ol { 171 | list-style-type: lower-roman; 172 | } 173 | 174 | .markdown-body ul ul ol, 175 | .markdown-body ul ol ol, 176 | .markdown-body ol ul ol, 177 | .markdown-body ol ol ol { 178 | list-style-type: lower-alpha; 179 | } 180 | 181 | .markdown-body dd { 182 | margin-left: 0; 183 | } 184 | 185 | .markdown-body pre { 186 | margin-top: 0; 187 | margin-bottom: 0; 188 | font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; 189 | font-size: 12px; 190 | } 191 | .markdown-body::before { 192 | display: table; 193 | content: ""; 194 | } 195 | 196 | .markdown-body::after { 197 | display: table; 198 | clear: both; 199 | content: ""; 200 | } 201 | 202 | .markdown-body>*:first-child { 203 | margin-top: 0 !important; 204 | } 205 | 206 | .markdown-body>*:last-child { 207 | margin-bottom: 0 !important; 208 | } 209 | 210 | .markdown-body a:not([href]) { 211 | color: inherit; 212 | text-decoration: none; 213 | } 214 | 215 | .markdown-body p, 216 | .markdown-body blockquote, 217 | .markdown-body ul, 218 | .markdown-body ol, 219 | .markdown-body dl, 220 | .markdown-body table, 221 | .markdown-body pre { 222 | margin-top: 0; 223 | margin-bottom: 16px; 224 | } 225 | 226 | .markdown-body hr { 227 | height: 0.25em; 228 | padding: 0; 229 | margin: 24px 0; 230 | background-color: #e1e4e8; 231 | border: 0; 232 | } 233 | 234 | .markdown-body blockquote { 235 | padding: 0 1em; 236 | color: #6a737d; 237 | border-left: 0.25em solid #dfe2e5; 238 | } 239 | 240 | .markdown-body blockquote>:first-child { 241 | margin-top: 0; 242 | } 243 | 244 | .markdown-body blockquote>:last-child { 245 | margin-bottom: 0; 246 | } 247 | 248 | .markdown-body h1, 249 | .markdown-body h2, 250 | .markdown-body h3, 251 | .markdown-body h4, 252 | .markdown-body h5, 253 | .markdown-body h6 { 254 | margin-top: 24px; 255 | margin-bottom: 16px; 256 | font-weight: 600; 257 | line-height: 1.25; 258 | } 259 | 260 | .markdown-body h1 { 261 | padding-bottom: 0.3em; 262 | font-size: 2em; 263 | border-bottom: 1px solid #eaecef; 264 | } 265 | 266 | .markdown-body h2 { 267 | padding-bottom: 0.3em; 268 | font-size: 1.5em; 269 | border-bottom: 1px solid #eaecef; 270 | } 271 | 272 | .markdown-body h3 { 273 | font-size: 1.25em; 274 | } 275 | 276 | .markdown-body h4 { 277 | font-size: 1em; 278 | } 279 | 280 | .markdown-body h5 { 281 | font-size: 0.875em; 282 | } 283 | 284 | .markdown-body h6 { 285 | font-size: 0.85em; 286 | color: #6a737d; 287 | } 288 | 289 | .markdown-body ul, 290 | .markdown-body ol { 291 | padding-left: 2em; 292 | } 293 | 294 | .markdown-body ul ul, 295 | .markdown-body ul ol, 296 | .markdown-body ol ol, 297 | .markdown-body ol ul { 298 | margin-top: 0; 299 | margin-bottom: 0; 300 | } 301 | 302 | .markdown-body li { 303 | word-wrap: break-all; 304 | } 305 | 306 | .markdown-body li>p { 307 | margin-top: 16px; 308 | } 309 | 310 | .markdown-body li+li { 311 | margin-top: 0.25em; 312 | } 313 | 314 | .markdown-body dl { 315 | padding: 0; 316 | } 317 | 318 | .markdown-body dl dt { 319 | padding: 0; 320 | margin-top: 16px; 321 | font-size: 1em; 322 | font-style: italic; 323 | font-weight: 600; 324 | } 325 | 326 | .markdown-body dl dd { 327 | padding: 0 16px; 328 | margin-bottom: 16px; 329 | } 330 | 331 | .markdown-body table { 332 | display: block; 333 | width: 100%; 334 | overflow: auto; 335 | } 336 | 337 | .markdown-body table th { 338 | font-weight: 600; 339 | } 340 | 341 | .markdown-body table th, 342 | .markdown-body table td { 343 | padding: 6px 13px; 344 | border: 1px solid #dfe2e5; 345 | } 346 | 347 | .markdown-body table tr { 348 | background-color: #fff; 349 | border-top: 1px solid #c6cbd1; 350 | } 351 | 352 | .markdown-body table tr:nth-child(2n) { 353 | background-color: #f6f8fa; 354 | } 355 | 356 | .markdown-body img { 357 | max-width: 100%; 358 | box-sizing: content-box; 359 | background-color: #fff; 360 | } 361 | 362 | .markdown-body img[align=right] { 363 | padding-left: 20px; 364 | } 365 | 366 | .markdown-body img[align=left] { 367 | padding-right: 20px; 368 | } 369 | 370 | .markdown-body pre { 371 | padding: 16px; 372 | overflow: auto; 373 | font-size: 85%; 374 | line-height: 1.45; 375 | background-color: #f6f8fa; 376 | border-radius: 3px; 377 | word-wrap: normal; 378 | } 379 | .markdown-body :checked+.radio-label { 380 | position: relative; 381 | z-index: 1; 382 | border-color: #0366d6; 383 | } 384 | 385 | .markdown-body hr { 386 | border-bottom-color: #eee; 387 | } 388 | 389 | #log .v { 390 | color: #888888; 391 | } 392 | 393 | #log .d { 394 | color: #00DDDD; 395 | } 396 | 397 | #log .c { 398 | color: magenta; 399 | } 400 | 401 | #log .i { 402 | color: limegreen; 403 | } 404 | 405 | #log .w { 406 | color: yellow; 407 | } 408 | 409 | #log .e { 410 | color: red; 411 | font-weight: bold; 412 | } 413 | 414 | #log { 415 | background-color: #1c1c1c; 416 | } 417 | -------------------------------------------------------------------------------- /packages/v1/src/webserver-v1.js: -------------------------------------------------------------------------------- 1 | const source = new EventSource("/events"); 2 | 3 | source.addEventListener('log', function (e) { 4 | const log = document.getElementById("log"); 5 | let log_prefs = [ 6 | ["\u001b[1;31m", 'e'], 7 | ["\u001b[0;33m", 'w'], 8 | ["\u001b[0;32m", 'i'], 9 | ["\u001b[0;35m", 'c'], 10 | ["\u001b[0;36m", 'd'], 11 | ["\u001b[0;37m", 'v'], 12 | ]; 13 | 14 | let klass = ''; 15 | let colorPrefix = ''; 16 | for (const log_pref of log_prefs){ 17 | if (e.data.startsWith(log_pref[0])) { 18 | klass = log_pref[1]; 19 | colorPrefix = log_pref[0]; 20 | } 21 | } 22 | 23 | if (klass == ''){ 24 | log.innerHTML += e.data + '\n'; 25 | return; 26 | } 27 | 28 | // Extract content without color codes and ANSI termination 29 | const content = e.data.substr(7, e.data.length - 11); 30 | 31 | // Split by newlines to handle multi-line messages 32 | const lines = content.split('\n'); 33 | 34 | // Extract header from first line (everything up to and including ']:') 35 | let header = ''; 36 | const headerMatch = lines[0].match(/^(.*?\]:)/); 37 | if (headerMatch) { 38 | header = headerMatch[1]; 39 | } 40 | 41 | // Process each line 42 | lines.forEach((line, index) => { 43 | if (line) { 44 | if (index === 0) { 45 | // First line - display as-is 46 | log.innerHTML += '' + line + "\n"; 47 | } else { 48 | // Continuation lines - prepend with header 49 | log.innerHTML += '' + header + line + "\n"; 50 | } 51 | } 52 | }); 53 | }); 54 | 55 | actions = [ 56 | ["switch", ["toggle"]], 57 | ["light", ["toggle"]], 58 | ["fan", ["toggle"]], 59 | ["cover", ["open", "close"]], 60 | ["button", ["press"]], 61 | ["lock", ["lock", "unlock", "open"]], 62 | ]; 63 | multi_actions = [ 64 | ["select", "option"], 65 | ["number", "value"], 66 | ]; 67 | 68 | source.addEventListener('state', function (e) { 69 | const data = JSON.parse(e.data); 70 | document.getElementById(data.id).children[1].innerText = data.state; 71 | }); 72 | 73 | const states = document.getElementById("states"); 74 | let i = 0, row; 75 | for (; row = states.rows[i]; i++) { 76 | if (!row.children[2].children.length) { 77 | continue; 78 | } 79 | 80 | for (const domain of actions){ 81 | if (row.classList.contains(domain[0])) { 82 | let id = row.id.substr(domain[0].length+1); 83 | for (let j=0;j'+o+"\n":''+i+o+"\n")}))})),actions=[["switch",["toggle"]],["light",["toggle"]],["fan",["toggle"]],["cover",["open","close"]],["button",["press"]],["lock",["lock","unlock","open"]]],multi_actions=[["select","option"],["number","value"]],source.addEventListener("state",(function(o){const t=JSON.parse(o.data);document.getElementById(t.id).children[1].innerText=t.state}));const states=document.getElementById("states");let row,i=0;for(;row=states.rows[i];i++)if(row.children[2].children.length){for(const o of actions)if(row.classList.contains(o[0])){let t=row.id.substr(o[0].length+1);for(let n=0;n 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /packages/v2/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@esphome-webserver/v2", 3 | "version": "2.0.0", 4 | "scripts": { 5 | "start": "npm run dev", 6 | "dev": "vite", 7 | "xbuild": "vite build --emptyOutDir", 8 | "build": "vite build --emptyOutDir && npm run deploy", 9 | "serve": "vite preview", 10 | "deploy": "bash -c '../../scripts/make_header.sh ../../_static/v2 server_index_v2.h web_server 2'" 11 | }, 12 | "dependencies": { 13 | "http-proxy-middleware": "^2.0.1", 14 | "lit": "^2.0.2" 15 | }, 16 | "devDependencies": { 17 | "rollup-plugin-copy": "^3.4.0", 18 | "rollup-plugin-gzip": "^2.5.0", 19 | "rollup-plugin-minify-html-template-literals": "^1.2.0", 20 | "@rollup/plugin-node-resolve": "^13.0.6", 21 | "@rollup/plugin-replace": "^3.0.0", 22 | "@types/node": "^15.12.1", 23 | "rollup-plugin-strip-banner": "^2.0.0", 24 | "typescript": "^4.1.3", 25 | "vite": "^2.3.6", 26 | "vite-plugin-html": "^2.1.1", 27 | "vite-plugin-package-version": "^1.0.2", 28 | "vite-plugin-singlefile": "^0.5.1" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/v2/public/home.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/v2/public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /packages/v2/src/css/button.ts: -------------------------------------------------------------------------------- 1 | import { css } from "lit"; 2 | 3 | export default css` 4 | button, 5 | .btn { 6 | cursor: pointer; 7 | border-radius: 4px; 8 | background-color: inherit; 9 | background-image: linear-gradient( 10 | 0deg, 11 | rgba(127, 127, 127, 0.5) 0%, 12 | rgba(127, 127, 127, 0.5) 100% 13 | ); 14 | color: inherit; 15 | border: 1px solid rgba(127, 127, 127, 0.5); 16 | padding: 2px; 17 | } 18 | 19 | button:active, 20 | .btn:active { 21 | background-image: linear-gradient( 22 | 0deg, 23 | rgba(127, 127, 127, 0.8) 0%, 24 | rgba(127, 127, 127, 0.2) 100% 25 | ); 26 | transition-duration: 1s; 27 | } 28 | 29 | button:hover, 30 | .btn:hover { 31 | background-image: linear-gradient( 32 | 0deg, 33 | rgba(127, 127, 127, 0.2) 0%, 34 | rgba(127, 127, 127, 0.8) 100% 35 | ); 36 | transition-duration: 1s; 37 | } 38 | 39 | .rnd { 40 | border-radius: 1rem; 41 | height: 2rem; 42 | width: 2rem; 43 | font-weight: 500; 44 | font-size: 1.2rem; 45 | } 46 | `; 47 | -------------------------------------------------------------------------------- /packages/v2/src/css/main.css: -------------------------------------------------------------------------------- 1 | 2 | /* First, declare your dark mode colors */ 3 | :root { 4 | --c-bg: #fff; 5 | --c-text: #000; 6 | --c-primary: #26a69a; 7 | --color:0, 100%; 8 | --l:50%; 9 | /* --color-primary: hsl(var(--color),var(--l)); 10 | https://blog.jim-nielsen.com/2019/generating-shades-of-color-using-css-variables/ 11 | */ 12 | --color-primary: #26a69a; 13 | 14 | --color-primary-darker: hsl(var(--color),calc(var(--l) - 5%)); 15 | --color-primary-darkest: hsl(var(--color),calc(var(--l) - 10%)); 16 | --color-text: #5b3e81; 17 | --color-text-rgb: 47, 6, 100; 18 | --color-primary-lighter: rgba(var(--color-text-rgb), 50%); 19 | --color-slider-thingy: 38, 166, 154; 20 | 21 | --primary-color: hsla(323, 18%, 49%, 0.924); 22 | --dark-primary-color: #0288d1; 23 | --light-primary-color: #b3e5fC; 24 | --c-pri-rgb: 3, 169, 244; 25 | --c-pri: rgba(var(--c-pri-rgb),100%); 26 | --c-pri-l: rgba(var(--c-pri-rgb), 50%); 27 | --c-pri-d: hsl(var(--c-pri-rgb),calc(var(--l) - 5%); 28 | --color-primary-lighter2: rgba(var(--c-pri), 50%)); 29 | } 30 | @media (prefers-color-scheme: dark) { 31 | :root { 32 | --c-bg: #000; 33 | --c-text: #fff; 34 | } 35 | } 36 | 37 | html { 38 | /* color:blanchedalmond */ 39 | } 40 | 41 | 42 | html[color-scheme="dark"] img { 43 | filter: invert(100%); 44 | } 45 | 46 | /* For browsers that don’t support `color-scheme` and therefore 47 | don't handle system dark mode for you automatically 48 | (Firefox), handle it for them. */ 49 | @supports not (color-scheme: light dark) { 50 | html { 51 | background: var(--c-bg); 52 | color: var(--c-text); 53 | } 54 | } 55 | 56 | /* For browsers that support automatic dark/light mode 57 | As well as system colors, set those */ 58 | @supports (color-scheme: light dark) 59 | and (background-color: Canvas) 60 | and (color: CanvasText) { 61 | :root { 62 | --c-bg: Canvas; 63 | --c-text: ButtonText; 64 | } 65 | } 66 | 67 | /* For Safari on iOS. Hacky, but it works. */ 68 | @supports (background-color: -apple-system-control-background) 69 | and (color: text) { 70 | :root { 71 | --c-bg: -apple-system-control-background; 72 | --c-text: text; 73 | } 74 | } 75 | 76 | html { 77 | color-scheme: light dark; 78 | font-family: ui-monospace, system-ui, "Helvetica", "Arial Narrow", "Roboto", "Oxygen", "Ubuntu", sans-serif; 79 | } 80 | 81 | html button, html .btn { 82 | cursor: pointer; 83 | border-radius: 1rem; 84 | background-color: inherit; 85 | background-image: linear-gradient( 0deg, rgba(127,127,127,0.5) 0%, rgba(127,127,127,0.5) 100%); 86 | color:inherit; 87 | border: 1px solid rgba(127,127,127,0.5); 88 | height: 1.2rem; 89 | 90 | 91 | html * { 92 | /* transition: all 750ms !important; */ 93 | /* transition-property: color-scheme; */ 94 | transition-property: color; 95 | transition-duration: 450ms !important; 96 | transition-timing-function: ease !important; 97 | transition-delay: 0s !important; 98 | } 99 | 100 | 101 | /* 102 | https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#system_colors 103 | ActiveText 104 | Text of active links 105 | 106 | ButtonBorder 107 | Base border color of controls 108 | 109 | ButtonFace 110 | Background color of controls 111 | 112 | ButtonText 113 | Foreground color of controls 114 | 115 | Canvas 116 | Background of application content or documents 117 | 118 | CanvasText 119 | Foreground color in application content or documents 120 | 121 | Field 122 | Background of input fields 123 | 124 | FieldText 125 | Text in input fields 126 | 127 | GrayText 128 | Foreground color for disabled items (e.g. a disabled control) 129 | 130 | Highlight 131 | Background of selected items 132 | 133 | HighlightText 134 | Foreground color of selected items 135 | 136 | LinkText 137 | Text of non-active, non-visited links 138 | 139 | Mark 140 | Background of text that has been specially marked (such as by the HTML mark element) 141 | 142 | MarkText 143 | Text that has been specially marked (such as by the HTML mark element) 144 | 145 | VisitedText 146 | Text of visited links 147 | */ 148 | 149 | /* 150 | https://github.com/Airmime/minstyle.io/blob/master/css/minstyle.io.css 151 | 152 | https://mwichary.medium.com/dark-theme-in-a-day-3518dde2955a 153 | 154 | 155 | https://github.com/material-components/material-web/blob/master/docs/theming.md 156 | 157 | --mdc-theme-primary The theme primary color. #6200ee 158 | --mdc-theme-secondary The theme secondary color. #018786 159 | --mdc-theme-surface The theme surface color. #ffffff 160 | --mdc-theme-background The theme background color. #ffffff 161 | --mdc-theme-on-primary Text and icons on top of a theme primary color background. #ffffff 162 | --mdc-theme-on-secondary Text and icons on top of a theme secondary color background. #ffffff 163 | --mdc-theme-on-surface Text and icons on top of a theme surface color background. #000000 164 | 165 | https://css-tricks.com/system-things/ 166 | 167 | 168 | https://blog.jim-nielsen.com/2021/css-system-colors/ 169 | 170 | */ 171 | 172 | /* 173 | html, :host { 174 | --default-bg: #f3f3f3; 175 | --default-font-color: rgba(0, 0, 0, 0.85); 176 | --mdc-theme-primary: #6200ee; 177 | } 178 | 179 | html[data-theme="dark"] { 180 | --default-bg: #25282c; 181 | --default-font-color: #ffffff; 182 | --mdc-theme-primary: #deccf8; 183 | } 184 | 185 | html[data-theme='dark'] img { 186 | filter: invert(100%); 187 | background-color: red; 188 | } 189 | 190 | html { 191 | color-scheme: dark; 192 | } 193 | */ 194 | 195 | /* :root { 196 | --color:0, 100%; 197 | --l:50%; 198 | 199 | --color-primary: hsl(var(--color),var(--l)); 200 | --color-primary-darker: hsl(var(--color),calc(var(--l) - 5%)); 201 | --color-primary-darkest: hsl(var(--color),calc(var(--l) - 10%)); 202 | } 203 | 204 | :root .button { 205 | display:inline-block; 206 | padding:10px 20px; 207 | cursor:pointer; 208 | color: -internal-light-dark(black, white); 209 | padding: 1px 6px; 210 | border-width: 2px; 211 | border-style: outset; 212 | border-color: -internal-light-dark(rgb(118, 118, 118), rgb(133, 133, 133)); 213 | } 214 | 215 | .button:hover, 216 | .button:focus { 217 | background: var(--color-primary-darker); 218 | } 219 | 220 | .button:active { 221 | background: var(--color-primary-darkest); 222 | } 223 | 224 | html.theme-toggle, 225 | html.theme-toggle *, 226 | html.theme-toggle *:before, 227 | html.theme-toggle *:after { 228 | transition: all 250ms !important; 229 | transition-delay: 0 !important; 230 | } 231 | 232 | 233 | html { 234 | --text-color-normal: #0a244d; 235 | --text-color-light: #8cabd9; 236 | } 237 | 238 | html[data-theme='dark'] { 239 | --text-color-normal: hsl(210, 10%, 62%); 240 | --text-color-light: hsl(210, 15%, 35%); 241 | --text-color-richer: hsl(210, 50%, 72%); 242 | --text-color-highlight: hsl(25, 70%, 45%); 243 | } 244 | html[data-theme='dark'] { 245 | --hue: 210; 246 | --accent-hue: 25; 247 | --text-color-normal: hsl(var(--hue), 10%, 62%); 248 | --text-color-highlight: hsl(var(--accent-hue), 70%, 45%); 249 | } 250 | 251 | * { 252 | -webkit-box-sizing: content-box; 253 | -moz-box-sizing: content-box; 254 | box-sizing: content-box; 255 | } 256 | 257 | *:before, 258 | *:after { 259 | -webkit-box-sizing: content-box; 260 | -moz-box-sizing: content-box; 261 | box-sizing: content-box; 262 | } 263 | 264 | html { 265 | box-sizing: border-box; 266 | } 267 | 268 | body { 269 | background-color: var(--default-bg); 270 | color: var(--default-font-color); 271 | -ms-text-size-adjust: 100%; 272 | -webkit-text-size-adjust: 100%; 273 | font-family: "Helvetica Neue Thin", "Helvetica", "Arial Narrow", "Roboto", "Oxygen", "Ubuntu", sans-serif; 274 | font-size: 16px; 275 | line-height: 1.5; 276 | word-wrap: break-word; 277 | } 278 | 279 | button { 280 | cursor: pointer; 281 | } 282 | */ 283 | 284 | 285 | -------------------------------------------------------------------------------- /packages/v2/src/css/reset.ts: -------------------------------------------------------------------------------- 1 | import { css } from "lit"; 2 | 3 | export default css` 4 | :host { 5 | font-family: ui-monospace, system-ui, "Helvetica", "Arial Narrow", "Roboto", 6 | "Oxygen", "Ubuntu", sans-serif; 7 | color-scheme: light dark; 8 | --primary-color: #03a9f4; 9 | transition: all 350ms !important; 10 | } 11 | `; 12 | -------------------------------------------------------------------------------- /packages/v2/src/esp-app.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css, PropertyValues, nothing } from "lit"; 2 | import { customElement, state, query } from "lit/decorators.js"; 3 | import { getBasePath } from "./esp-entity-table"; 4 | 5 | import "./esp-entity-table"; 6 | import "./esp-log"; 7 | import "./esp-switch"; 8 | import "./esp-logo"; 9 | import cssReset from "./css/reset"; 10 | import cssButton from "./css/button"; 11 | 12 | window.source = new EventSource(getBasePath() + "/events"); 13 | 14 | interface Config { 15 | ota: boolean; 16 | log: boolean; 17 | title: string; 18 | comment: string; 19 | } 20 | 21 | @customElement("esp-app") 22 | export default class EspApp extends LitElement { 23 | @state() scheme: string = ""; 24 | @state() ping: string = ""; 25 | @query("#beat") 26 | beat!: HTMLSpanElement; 27 | 28 | version: String = import.meta.env.PACKAGE_VERSION; 29 | config: Config = { ota: false, log: true, title: "", comment: "" }; 30 | 31 | darkQuery: MediaQueryList = window.matchMedia("(prefers-color-scheme: dark)"); 32 | 33 | frames = [ 34 | { color: "inherit" }, 35 | { color: "red", transform: "scale(1.25) translateY(-30%)" }, 36 | { color: "inherit" }, 37 | ]; 38 | 39 | constructor() { 40 | super(); 41 | const conf = document.querySelector('script#config'); 42 | if ( conf ) this.setConfig(JSON.parse(conf.innerText)); 43 | } 44 | 45 | setConfig(config: any) { 46 | if (!("log" in config)) { 47 | config.log = this.config.log; 48 | } 49 | this.config = config; 50 | 51 | document.title = config.title; 52 | document.documentElement.lang = config.lang; 53 | } 54 | 55 | firstUpdated(changedProperties: PropertyValues) { 56 | super.firstUpdated(changedProperties); 57 | document.getElementsByTagName("head")[0].innerHTML += 58 | ''; 59 | const l = document.querySelector("link[rel~='icon']"); // Set favicon to house 60 | l.href = 61 | 'data:image/svg+xml,'; 62 | this.darkQuery.addEventListener("change", () => { 63 | this.scheme = this.isDark(); 64 | }); 65 | this.scheme = this.isDark(); 66 | window.source.addEventListener("ping", (e: Event) => { 67 | const messageEvent = e as MessageEvent; 68 | const d: String = messageEvent.data; 69 | if (d.length) { 70 | this.setConfig(JSON.parse(messageEvent.data)); 71 | } 72 | this.ping = messageEvent.lastEventId; 73 | }); 74 | window.source.onerror = function (e: Event) { 75 | console.dir(e); 76 | //alert("Lost event stream!") 77 | }; 78 | } 79 | 80 | isDark() { 81 | return this.darkQuery.matches ? "dark" : "light"; 82 | } 83 | 84 | updated(changedProperties: Map) { 85 | super.updated(changedProperties); 86 | if (changedProperties.has("scheme")) { 87 | let el = document.documentElement; 88 | document.documentElement.style.setProperty("color-scheme", this.scheme); 89 | } 90 | if (changedProperties.has("ping")) { 91 | this.beat.animate(this.frames, 1000); 92 | } 93 | } 94 | 95 | ota() { 96 | if (this.config.ota) { 97 | let basePath = getBasePath(); 98 | return html`

OTA Update

99 |
104 | 105 | 106 |
`; 107 | } 108 | } 109 | 110 | renderComment() { 111 | return this.config.comment 112 | ? html`

${this.config.comment}

` 113 | : nothing; 114 | } 115 | 116 | renderLog() { 117 | return this.config.log 118 | ? html`
` 119 | : nothing; 120 | } 121 | 122 | render() { 123 | return html` 124 |

125 | 128 | ${this.config.title} 129 | 130 |

131 | ${this.renderComment()} 132 |
133 |
134 | 135 |

136 | 147 | 148 | Scheme 149 |

150 | ${this.ota()} 151 |
152 | ${this.renderLog()} 153 |
154 | `; 155 | } 156 | 157 | static get styles() { 158 | return [ 159 | cssReset, 160 | cssButton, 161 | css` 162 | .flex-grid { 163 | display: flex; 164 | } 165 | .flex-grid .col { 166 | flex: 2; 167 | } 168 | .flex-grid-half { 169 | display: flex; 170 | justify-content: space-evenly; 171 | } 172 | .col { 173 | width: 48%; 174 | } 175 | 176 | @media (max-width: 600px) { 177 | .flex-grid, 178 | .flex-grid-half { 179 | display: block; 180 | } 181 | .col { 182 | width: 100%; 183 | margin: 0 0 10px 0; 184 | } 185 | } 186 | 187 | * { 188 | box-sizing: border-box; 189 | } 190 | .flex-grid { 191 | margin: 0 0 20px 0; 192 | } 193 | h1 { 194 | text-align: center; 195 | width: 100%; 196 | line-height: 4rem; 197 | } 198 | h1, 199 | h2 { 200 | border-bottom: 1px solid #eaecef; 201 | margin-bottom: 0.25rem; 202 | } 203 | h3 { 204 | text-align: center; 205 | margin: 0.5rem 0; 206 | } 207 | #beat { 208 | float: right; 209 | height: 1rem; 210 | } 211 | a.logo { 212 | height: 4rem; 213 | float: left; 214 | color: inherit; 215 | } 216 | .right { 217 | float: right; 218 | } 219 | `, 220 | ]; 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /packages/v2/src/esp-entity-table.ts: -------------------------------------------------------------------------------- 1 | import { html, css, LitElement, TemplateResult } from "lit"; 2 | import { customElement, state } from "lit/decorators.js"; 3 | import cssReset from "./css/reset"; 4 | import cssButton from "./css/button"; 5 | 6 | interface entityConfig { 7 | unique_id: string; 8 | domain: string; 9 | id: string; 10 | state: string; 11 | detail: string; 12 | value: string; 13 | name: string; 14 | when: string; 15 | icon?: string; 16 | option?: string[]; 17 | assumed_state?: boolean; 18 | brightness?: number; 19 | target_temperature?: number; 20 | target_temperature_low?: number; 21 | target_temperature_high?: number; 22 | min_temp?: number; 23 | max_temp?: number; 24 | min_value?: number; 25 | max_value?: number; 26 | step?: number; 27 | min_length?: number; 28 | max_length?: number; 29 | pattern?: string; 30 | current_temperature?: number; 31 | modes?: number[]; 32 | mode?: number; 33 | speed_count?: number; 34 | speed_level?: number; 35 | speed: string; 36 | effects?: string[]; 37 | effect?: string; 38 | has_action?: boolean; 39 | } 40 | 41 | export function getBasePath() { 42 | let str = window.location.pathname; 43 | return str.endsWith("/") ? str.slice(0, -1) : str; 44 | } 45 | 46 | let basePath = getBasePath(); 47 | 48 | interface RestAction { 49 | restAction(entity?: entityConfig, action?: string): void; 50 | } 51 | 52 | @customElement("esp-entity-table") 53 | export class EntityTable extends LitElement implements RestAction { 54 | @state() entities: entityConfig[] = []; 55 | @state() has_controls: boolean = false; 56 | 57 | private _actionRenderer = new ActionRenderer(); 58 | 59 | connectedCallback() { 60 | super.connectedCallback(); 61 | window.source?.addEventListener("state", (e: Event) => { 62 | const messageEvent = e as MessageEvent; 63 | const data = JSON.parse(messageEvent.data); 64 | let idx = this.entities.findIndex((x) => x.unique_id === data.id); 65 | if (idx === -1 && data.id) { 66 | // Dynamically add discovered.. 67 | let parts = data.id.split("-"); 68 | let entity = { 69 | ...data, 70 | domain: parts[0], 71 | unique_id: data.id, 72 | id: parts.slice(1).join("-"), 73 | } as entityConfig; 74 | entity.has_action = this.hasAction(entity); 75 | if (entity.has_action) { 76 | this.has_controls = true; 77 | } 78 | this.entities.push(entity); 79 | this.entities.sort((a, b) => (a.name < b.name ? -1 : 1)); 80 | this.requestUpdate(); 81 | } else { 82 | delete data.id; 83 | delete data.domain; 84 | delete data.unique_id; 85 | Object.assign(this.entities[idx], data); 86 | this.requestUpdate(); 87 | } 88 | }); 89 | } 90 | 91 | hasAction(entity: entityConfig): boolean { 92 | return `render_${entity.domain}` in this._actionRenderer; 93 | } 94 | 95 | control(entity: entityConfig) { 96 | this._actionRenderer.entity = entity; 97 | this._actionRenderer.actioner = this; 98 | return this._actionRenderer.exec( 99 | `render_${entity.domain}` as ActionRendererMethodKey 100 | ); 101 | } 102 | 103 | restAction(entity: entityConfig, action: string) { 104 | fetch(`${basePath}/${entity.domain}/${entity.id}/${action}`, { 105 | method: "POST", 106 | headers:{ 107 | 'Content-Type': 'application/x-www-form-urlencoded' 108 | }, 109 | }).then((r) => { 110 | console.log(r); 111 | }); 112 | } 113 | 114 | render() { 115 | return html` 116 | 117 | 118 | 119 | 120 | 121 | ${this.has_controls ? html`` : html``} 122 | 123 | 124 | 125 | ${this.entities.map( 126 | (component) => html` 127 | 128 | 129 | 130 | ${this.has_controls 131 | ? html`` 134 | : html``} 135 | 136 | ` 137 | )} 138 | 139 |
NameStateActions
${component.name}${component.state} 132 | ${component.has_action ? this.control(component) : html``} 133 |
140 | `; 141 | } 142 | 143 | static get styles() { 144 | return [ 145 | cssReset, 146 | cssButton, 147 | css` 148 | table { 149 | border-spacing: 0; 150 | border-collapse: collapse; 151 | width: 100%; 152 | border: 1px solid currentColor; 153 | background-color: var(--c-bg); 154 | } 155 | th { 156 | font-weight: 600; 157 | text-align: left; 158 | } 159 | th, 160 | td { 161 | padding: 0.25rem 0.5rem; 162 | border: 1px solid currentColor; 163 | } 164 | td:nth-child(2), 165 | th:nth-child(2) { 166 | text-align: center; 167 | } 168 | tr th, 169 | tr:nth-child(2n) { 170 | background-color: rgba(127, 127, 127, 0.3); 171 | } 172 | select { 173 | background-color: inherit; 174 | color: inherit; 175 | width: 100%; 176 | border-radius: 4px; 177 | } 178 | option { 179 | color: currentColor; 180 | background-color: var(--primary-color, currentColor); 181 | } 182 | input[type="range"], input[type="text"] { 183 | width: calc(100% - 8rem); 184 | min-width: 5rem; 185 | height: 0.75rem; 186 | } 187 | .range, .text { 188 | text-align: center; 189 | } 190 | `, 191 | ]; 192 | } 193 | } 194 | 195 | type ActionRendererNonCallable = "entity" | "actioner" | "exec"; 196 | type ActionRendererMethodKey = keyof Omit< 197 | ActionRenderer, 198 | ActionRendererNonCallable 199 | >; 200 | 201 | class ActionRenderer { 202 | public entity?: entityConfig; 203 | public actioner?: RestAction; 204 | 205 | exec(method: ActionRendererMethodKey) { 206 | if (!this[method] || typeof this[method] !== "function") { 207 | console.log(`ActionRenderer.${method} is not callable`); 208 | return; 209 | } 210 | return this[method](); 211 | } 212 | 213 | private _actionButton(entity: entityConfig, label: string, action: string) { 214 | if (!entity) return; 215 | let a = action || label.toLowerCase(); 216 | return html``; 222 | } 223 | 224 | private _switch(entity: entityConfig) { 225 | return html``; 233 | } 234 | 235 | private _select( 236 | entity: entityConfig, 237 | action: string, 238 | opt: string, 239 | options: string[] | number[], 240 | val: string | number | undefined 241 | ) { 242 | return html``; 260 | } 261 | 262 | private _range( 263 | entity: entityConfig, 264 | action: string, 265 | opt: string, 266 | value: string | number, 267 | min: number | undefined, 268 | max: number | undefined, 269 | step: number | undefined 270 | ) { 271 | return html`
272 | 273 | 286 | 287 |
`; 288 | } 289 | 290 | private _datetime( 291 | entity: entityConfig, 292 | type: string, 293 | action: string, 294 | opt: string, 295 | value: string, 296 | ) { 297 | return html` 298 | 311 | `; 312 | } 313 | 314 | private _textinput( 315 | entity: entityConfig, 316 | action: string, 317 | opt: string, 318 | value: string | number, 319 | min: number | undefined, 320 | max: number | undefined, 321 | pattern: string | undefined 322 | ) { 323 | return html`
324 | 337 |
`; 338 | } 339 | 340 | render_switch() { 341 | if (!this.entity) return; 342 | if (this.entity.assumed_state) 343 | return html`${this._actionButton(this.entity, "❌", "turn_off")} 344 | ${this._actionButton(this.entity, "✔️", "turn_on")}`; 345 | else return this._switch(this.entity); 346 | } 347 | 348 | render_fan() { 349 | if (!this.entity) return; 350 | return [ 351 | this.entity.speed, 352 | " ", 353 | this.entity.speed_level, 354 | this._switch(this.entity), 355 | this.entity.speed_count 356 | ? this._range( 357 | this.entity, 358 | `turn_${this.entity.state.toLowerCase()}`, 359 | "speed_level", 360 | this.entity.speed_level ? this.entity.speed_level : 0, 361 | 0, 362 | this.entity.speed_count, 363 | 1 364 | ) 365 | : "", 366 | ]; 367 | } 368 | 369 | render_light() { 370 | if (!this.entity) return; 371 | return [ 372 | this._switch(this.entity), 373 | this.entity.brightness 374 | ? this._range( 375 | this.entity, 376 | "turn_on", 377 | "brightness", 378 | this.entity.brightness, 379 | 0, 380 | 255, 381 | 1 382 | ) 383 | : "", 384 | this.entity.effects?.filter((v) => v != "None").length 385 | ? this._select( 386 | this.entity, 387 | "turn_on", 388 | "effect", 389 | this.entity.effects || [], 390 | this.entity.effect 391 | ) 392 | : "", 393 | ]; 394 | } 395 | 396 | render_lock() { 397 | if (!this.entity) return; 398 | return html`${this._actionButton(this.entity, "🔐", "lock")} 399 | ${this._actionButton(this.entity, "🔓", "unlock")} 400 | ${this._actionButton(this.entity, "↑", "open")} `; 401 | } 402 | 403 | render_cover() { 404 | if (!this.entity) return; 405 | return html`${this._actionButton(this.entity, "↑", "open")} 406 | ${this._actionButton(this.entity, "☐", "stop")} 407 | ${this._actionButton(this.entity, "↓", "close")}`; 408 | } 409 | 410 | render_button() { 411 | if (!this.entity) return; 412 | return html`${this._actionButton(this.entity, "☐", "press ")}`; 413 | } 414 | 415 | render_select() { 416 | if (!this.entity) return; 417 | return this._select( 418 | this.entity, 419 | "set", 420 | "option", 421 | this.entity.option || [], 422 | this.entity.value 423 | ); 424 | } 425 | 426 | render_number() { 427 | if (!this.entity) return; 428 | return this._range( 429 | this.entity, 430 | "set", 431 | "value", 432 | this.entity.value, 433 | this.entity.min_value, 434 | this.entity.max_value, 435 | this.entity.step 436 | ); 437 | } 438 | 439 | render_date() { 440 | if (!this.entity) return; 441 | return html` 442 | ${this._datetime( 443 | this.entity, 444 | "date", 445 | "set", 446 | "value", 447 | this.entity.value, 448 | )} 449 | `; 450 | } 451 | 452 | render_time() { 453 | if (!this.entity) return; 454 | return html` 455 | ${this._datetime( 456 | this.entity, 457 | "time", 458 | "set", 459 | "value", 460 | this.entity.value, 461 | )} 462 | `; 463 | } 464 | 465 | render_datetime() { 466 | if (!this.entity) return; 467 | return html` 468 | ${this._datetime( 469 | this.entity, 470 | "datetime-local", 471 | "set", 472 | "value", 473 | this.entity.value, 474 | )} 475 | `; 476 | } 477 | 478 | render_text() { 479 | if (!this.entity) return; 480 | return this._textinput( 481 | this.entity, 482 | "set", 483 | "value", 484 | this.entity.value, 485 | this.entity.min_length, 486 | this.entity.max_length, 487 | this.entity.pattern, 488 | ); 489 | } 490 | 491 | render_climate() { 492 | if (!this.entity) return; 493 | let target_temp_slider, target_temp_label; 494 | if ( 495 | this.entity.target_temperature_low !== undefined && 496 | this.entity.target_temperature_high !== undefined 497 | ) { 498 | target_temp_label = html`${this.entity 499 | .target_temperature_low} .. ${this.entity 500 | .target_temperature_high}`; 501 | target_temp_slider = html` 502 | ${this._range( 503 | this.entity, 504 | "set", 505 | "target_temperature_low", 506 | this.entity.target_temperature_low, 507 | this.entity.min_temp, 508 | this.entity.max_temp, 509 | this.entity.step 510 | )} 511 | ${this._range( 512 | this.entity, 513 | "set", 514 | "target_temperature_high", 515 | this.entity.target_temperature_high, 516 | this.entity.min_temp, 517 | this.entity.max_temp, 518 | this.entity.step 519 | )} 520 | `; 521 | } else { 522 | target_temp_label = html`${this.entity.target_temperature}`; 523 | target_temp_slider = html` 524 | ${this._range( 525 | this.entity, 526 | "set", 527 | "target_temperature", 528 | this.entity.target_temperature!!, 529 | this.entity.min_temp, 530 | this.entity.max_temp, 531 | this.entity.step 532 | )} 533 | `; 534 | } 535 | let modes = html``; 536 | if ((this.entity.modes ? this.entity.modes.length : 0) > 0) { 537 | modes = html`Mode:
538 | ${this._select( 539 | this.entity, 540 | "set", 541 | "mode", 542 | this.entity.modes || [], 543 | this.entity.mode || "" 544 | )}`; 545 | } 546 | return html` 547 | 551 | ${target_temp_slider} ${modes} 552 | `; 553 | } 554 | render_valve() { 555 | if (!this.entity) return; 556 | return html`${this._actionButton(this.entity, "| |", "open")} 557 | ${this._actionButton(this.entity, "☐", "stop")} 558 | ${this._actionButton(this.entity, "|-|", "close")}`; 559 | } 560 | } 561 | -------------------------------------------------------------------------------- /packages/v2/src/esp-log.ts: -------------------------------------------------------------------------------- 1 | import { html, css, LitElement } from "lit"; 2 | import { customElement, property, state } from "lit/decorators.js"; 3 | 4 | interface recordConfig { 5 | type: string; 6 | level: string; 7 | tag: string; 8 | detail: string; 9 | when: string; 10 | } 11 | 12 | @customElement("esp-log") 13 | export class DebugLog extends LitElement { 14 | @property({ type: Number }) rows = 10; 15 | @state() logs: recordConfig[] = []; 16 | 17 | constructor() { 18 | super(); 19 | } 20 | 21 | connectedCallback() { 22 | super.connectedCallback(); 23 | window.source?.addEventListener("log", (e: Event) => { 24 | const messageEvent = e as MessageEvent; 25 | const d: String = messageEvent.data; 26 | 27 | const types: Record = { 28 | "": "e", 29 | "": "w", 30 | "": "i", 31 | "": "c", 32 | "": "d", 33 | "": "v", 34 | }; 35 | 36 | // Extract the type from the color code 37 | const type = types[d.slice(0, 7)]; 38 | if (!type) { 39 | // No color code, skip 40 | return; 41 | } 42 | 43 | // Extract content without color codes and ANSI termination 44 | const content = d.slice(7, d.length - 4); 45 | 46 | // Split by newlines to handle multi-line messages 47 | const lines = content.split('\n'); 48 | 49 | // Process the first line to extract metadata 50 | const firstLine = lines[0]; 51 | const parts = firstLine.slice(3).split(":"); 52 | const tag = parts.slice(0, 2).join(":"); 53 | const firstDetail = firstLine.slice(5 + tag.length); 54 | const level = firstLine.slice(0, 3); 55 | const when = new Date().toTimeString().split(" ")[0]; 56 | 57 | // Create a log record for each line 58 | lines.forEach((line, index) => { 59 | const record = { 60 | type: type, 61 | level: level, 62 | tag: tag, 63 | detail: index === 0 ? firstDetail : line, 64 | when: when, 65 | } as recordConfig; 66 | this.logs.push(record); 67 | }); 68 | 69 | this.logs = this.logs.slice(-this.rows); 70 | }); 71 | } 72 | 73 | render() { 74 | return html` 75 |
76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | ${this.logs.map( 87 | (log: recordConfig) => 88 | html` 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | ` 97 | )} 98 | 99 |
TimelevelTagMessage
${log.when}${log.level}${log.tag}
${log.detail}
100 |
101 | `; 102 | } 103 | 104 | static get styles() { 105 | return css` 106 | table { 107 | font-family: monospace; 108 | background-color: #1c1c1c; 109 | color: white; 110 | width: 100%; 111 | border: 1px solid #dfe2e5; 112 | line-height: 1; 113 | } 114 | 115 | thead { 116 | border: 1px solid #dfe2e5; 117 | line-height: 1rem; 118 | } 119 | th { 120 | text-align: left; 121 | } 122 | th, 123 | td { 124 | padding: 0.25rem 0.5rem; 125 | } 126 | pre { 127 | margin: 0; 128 | } 129 | .v { 130 | color: #888888; 131 | } 132 | .d { 133 | color: #00dddd; 134 | } 135 | .c { 136 | color: magenta; 137 | } 138 | .i { 139 | color: limegreen; 140 | } 141 | .w { 142 | color: yellow; 143 | } 144 | .e { 145 | color: red; 146 | font-weight: bold; 147 | } 148 | .flow-x { 149 | overflow-x: auto; 150 | } 151 | `; 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /packages/v2/src/esp-logo.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, svg } from "lit"; 2 | import { customElement } from "lit/decorators.js"; 3 | 4 | import logo from "/logo.svg?raw"; 5 | 6 | @customElement("esp-logo") 7 | export default class EspLogo extends LitElement { 8 | render() { 9 | return svg([logo]); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/v2/src/esp-switch.ts: -------------------------------------------------------------------------------- 1 | import { html, css, LitElement } from "lit"; 2 | import { customElement, property } from "lit/decorators.js"; 3 | import cssReset from "./css/reset"; 4 | 5 | const checkboxID: string = "checkbox-lever"; 6 | 7 | @customElement("esp-switch") 8 | export class EspSwitch extends LitElement { 9 | private checkbox: HTMLInputElement | null = null; 10 | 11 | // Use arrays - or slots 12 | @property({ type: String }) labelOn = "On"; 13 | @property({ type: String }) labelOff = "Off"; 14 | @property({ type: String }) stateOn = "ON"; 15 | @property({ type: String }) stateOff = "OFF"; 16 | @property({ type: String }) state = "OFF"; 17 | @property({ type: String }) color = "currentColor"; 18 | @property({ type: Boolean }) disabled = false; 19 | 20 | protected firstUpdated( 21 | _changedProperties: Map 22 | ): void { 23 | this.checkbox = this.shadowRoot?.getElementById( 24 | checkboxID 25 | ) as HTMLInputElement; 26 | } 27 | 28 | private isOn(): boolean { 29 | return this.state === this.stateOn; 30 | } 31 | 32 | toggle(ev: Event): void { 33 | const newState = this.isOn() ? this.stateOff : this.stateOn; 34 | let event = new CustomEvent("state", { 35 | detail: { 36 | state: newState, 37 | id: this.id, 38 | }, 39 | }); 40 | this.dispatchEvent(event); 41 | } 42 | 43 | render() { 44 | return html` 45 |
46 | 58 |
59 | `; 60 | } 61 | 62 | static get styles() { 63 | return [ 64 | cssReset, 65 | css` 66 | .sw, 67 | .sw * { 68 | -webkit-tap-highlight-color: transparent; 69 | user-select: none; 70 | cursor: pointer; 71 | } 72 | 73 | input[type="checkbox"] { 74 | opacity: 0; 75 | width: 0; 76 | height: 0; 77 | } 78 | 79 | input[type="checkbox"]:checked + .lever { 80 | background-color: currentColor; 81 | background-image: linear-gradient( 82 | 0deg, 83 | rgba(255, 255, 255, 0.5) 0%, 84 | rgba(255, 255, 255, 0.5) 100% 85 | ); 86 | } 87 | 88 | input[type="checkbox"]:checked + .lever:before, 89 | input[type="checkbox"]:checked + .lever:after { 90 | left: 18px; 91 | } 92 | 93 | input[type="checkbox"]:checked + .lever:after { 94 | background-color: currentColor; 95 | } 96 | 97 | .lever { 98 | content: ""; 99 | display: inline-block; 100 | position: relative; 101 | width: 36px; 102 | height: 14px; 103 | background-image: linear-gradient( 104 | 0deg, 105 | rgba(127, 127, 127, 0.5) 0%, 106 | rgba(127, 127, 127, 0.5) 100% 107 | ); 108 | background-color: inherit; 109 | border-radius: 15px; 110 | margin-right: 10px; 111 | transition: background 0.3s ease; 112 | vertical-align: middle; 113 | margin: 0 16px; 114 | } 115 | 116 | .lever:before, 117 | .lever:after { 118 | content: ""; 119 | position: absolute; 120 | display: inline-block; 121 | width: 20px; 122 | height: 20px; 123 | border-radius: 50%; 124 | left: 0; 125 | top: -3px; 126 | transition: left 0.3s ease, background 0.3s ease, box-shadow 0.1s ease, 127 | transform 0.1s ease; 128 | } 129 | 130 | .lever:before { 131 | background-color: currentColor; 132 | background-image: linear-gradient( 133 | 0deg, 134 | rgba(255, 255, 255, 0.9) 0%, 135 | rgba(255, 255, 255, 0.9) 100% 136 | ); 137 | } 138 | 139 | .lever:after { 140 | background-color: #f1f1f1; 141 | box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.2), 142 | 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 143 | 0px 1px 5px 0px rgba(0, 0, 0, 0.12); 144 | } 145 | 146 | input[type="checkbox"]:checked:not(:disabled) ~ .lever:active::before, 147 | input[type="checkbox"]:checked:not(:disabled).tabbed:focus 148 | ~ .lever::before { 149 | transform: scale(2.4); 150 | background-color: currentColor; 151 | background-image: linear-gradient( 152 | 0deg, 153 | rgba(255, 255, 255, 0.9) 0%, 154 | rgba(255, 255, 255, 0.9) 100% 155 | ); 156 | } 157 | 158 | input[type="checkbox"]:not(:disabled) ~ .lever:active:before, 159 | input[type="checkbox"]:not(:disabled).tabbed:focus ~ .lever::before { 160 | transform: scale(2.4); 161 | background-color: rgba(0, 0, 0, 0.08); 162 | } 163 | 164 | input[type="checkbox"][disabled] + .lever { 165 | cursor: default; 166 | background-color: rgba(0, 0, 0, 0.12); 167 | } 168 | 169 | input[type="checkbox"][disabled] + .lever:after, 170 | input[type="checkbox"][disabled]:checked + .lever:after { 171 | background-color: #949494; 172 | } 173 | `, 174 | ]; 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /packages/v2/src/main.ts: -------------------------------------------------------------------------------- 1 | import "./esp-app" 2 | -------------------------------------------------------------------------------- /packages/v2/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import gzipPlugin from "rollup-plugin-gzip"; 3 | import minifyHTML from "rollup-plugin-minify-html-template-literals"; 4 | import { brotliCompressSync } from "zlib"; 5 | import { nodeResolve } from "@rollup/plugin-node-resolve"; 6 | import loadVersion from "vite-plugin-package-version"; 7 | import { viteSingleFile } from "vite-plugin-singlefile"; 8 | import { minifyHtml as ViteMinifyHtml } from "vite-plugin-html"; 9 | import stripBanner from "rollup-plugin-strip-banner"; 10 | import replace from "@rollup/plugin-replace"; 11 | 12 | const proxy_target = process.env.PROXY_TARGET || "http://nodemcu.local"; 13 | 14 | export default defineConfig({ 15 | clearScreen: false, 16 | plugins: [ 17 | { 18 | ...nodeResolve({ exportConditions: ["development"] }), 19 | enforce: "pre", 20 | apply: "start", 21 | }, 22 | stripBanner(), 23 | loadVersion(), 24 | { ...minifyHTML(), enforce: "pre", apply: "build" }, 25 | { 26 | ...ViteMinifyHtml({ removeComments: true }), 27 | enforce: "post", 28 | apply: "build", 29 | }, 30 | replace({ 31 | "@license": "license", 32 | "Value passed to 'css' function must be a 'css' function result:": 33 | "use css function", 34 | "Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.": 35 | "Use unsafeCSS", 36 | delimiters: ["", ""], 37 | preventAssignment: true, 38 | }), 39 | viteSingleFile(), 40 | { 41 | ...gzipPlugin({ 42 | filter: /\.(js|css|html|svg)$/, 43 | additionalFiles: [], 44 | customCompression: (content) => 45 | brotliCompressSync(Buffer.from(content)), 46 | fileName: ".br", 47 | }), 48 | enforce: "post", 49 | apply: "build", 50 | }, 51 | { 52 | ...gzipPlugin({ filter: /\.(js|css|html|svg)$/ }), 53 | enforce: "post", 54 | apply: "build", 55 | }, 56 | ], 57 | build: { 58 | brotliSize: false, 59 | // cssCodeSplit: true, 60 | outDir: "../../_static/v2", 61 | polyfillModulePreload: false, 62 | rollupOptions: { 63 | output: { 64 | manualChunks: (chunk) => { 65 | return "vendor"; 66 | }, // create one js bundle, 67 | chunkFileNames: "[name].js", 68 | assetFileNames: "www[extname]", 69 | entryFileNames: "www.js", 70 | }, 71 | }, 72 | }, 73 | server: { 74 | open: "/", // auto open browser in dev mode 75 | host: true, // dev on local and network 76 | port: 5001, 77 | strictPort: true, 78 | proxy: { 79 | "/light": proxy_target, 80 | "/select": proxy_target, 81 | "/cover": proxy_target, 82 | "/switch": proxy_target, 83 | "/button": proxy_target, 84 | "/fan": proxy_target, 85 | "/lock": proxy_target, 86 | "/number": proxy_target, 87 | "/climate": proxy_target, 88 | "/events": proxy_target, 89 | "/text": proxy_target, 90 | "/date": proxy_target, 91 | "/time": proxy_target, 92 | "/valve": proxy_target, 93 | }, 94 | }, 95 | }); 96 | -------------------------------------------------------------------------------- /packages/v3/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /packages/v3/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@esphome-webserver/v3", 3 | "version": "3.0.0", 4 | "scripts": { 5 | "start": "npm run dev", 6 | "dev": "vite", 7 | "xbuild": "vite build --emptyOutDir", 8 | "build": "vite build --emptyOutDir && npm run deploy", 9 | "serve": "vite preview", 10 | "deploy": "bash -c '../../scripts/make_header.sh ../../_static/v3 server_index_v3.h web_server 3'" 11 | }, 12 | "dependencies": { 13 | "chart.js": "^4.4.1", 14 | "http-proxy-middleware": "^2.0.1", 15 | "iconify-icon": "^1.0.8", 16 | "lit": "^2.0.2" 17 | }, 18 | "devDependencies": { 19 | "rollup-plugin-copy": "^3.4.0", 20 | "rollup-plugin-gzip": "^2.5.0", 21 | "rollup-plugin-minify-html-template-literals": "^1.2.0", 22 | "@rollup/plugin-node-resolve": "^13.0.6", 23 | "@rollup/plugin-replace": "^3.0.0", 24 | "@types/node": "^15.12.1", 25 | "rollup-plugin-strip-banner": "^2.0.0", 26 | "typescript": "^4.1.3", 27 | "vite": "^2.3.6", 28 | "vite-plugin-html": "^2.1.1", 29 | "vite-plugin-package-version": "^1.0.2", 30 | "vite-plugin-singlefile": "^0.5.1" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/v3/public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /packages/v3/src/css/app.ts: -------------------------------------------------------------------------------- 1 | import { css } from "lit"; 2 | 3 | export default css` 4 | .flex-grid-half { 5 | display: grid; 6 | grid-template-columns: 700px 2fr; 7 | } 8 | .flex-grid-half.expanded_entity, 9 | .flex-grid-half.expanded_logs { 10 | grid-template-columns: 1fr; 11 | } 12 | .flex-grid-half .col { 13 | margin: 8px; 14 | } 15 | .flex-grid-half .col:nth-child(2) { 16 | overflow: hidden; 17 | } 18 | .flex-grid-half.expanded_logs .col:nth-child(1) { 19 | display: none; 20 | } 21 | .flex-grid-half.expanded_entity .col:nth-child(2) { 22 | display: none; 23 | } 24 | 25 | @media (max-width: 1024px) { 26 | .flex-grid, 27 | .flex-grid-half { 28 | display: block; 29 | } 30 | .flex-grid-half .col { 31 | width: 100% !important; 32 | margin: 0 0 10px 0 !important; 33 | display: block !important; 34 | } 35 | } 36 | 37 | * { 38 | box-sizing: border-box; 39 | } 40 | .flex-grid { 41 | margin: 0 0 20px 0; 42 | } 43 | h1 { 44 | text-align: center; 45 | width: 100%; 46 | line-height: 1.1em; 47 | margin-block: 0.25em; 48 | } 49 | header div { 50 | text-align: center; 51 | width: 100%; 52 | } 53 | header #logo, 54 | header iconify-icon { 55 | float: right; 56 | font-size: 2.5rem; 57 | color: rgba(127, 127, 127, 0.5); 58 | } 59 | header #logo { 60 | float: left; 61 | color: rgba(127, 127, 127, 0.5); 62 | } 63 | .connected { 64 | color: rgba(0, 157, 16, 0.75); 65 | } 66 | esp-logo { 67 | float: left; 68 | line-height: 1em; 69 | font-size: initial; 70 | } 71 | form { 72 | display: flex; 73 | justify-content: space-between; 74 | background-color: rgba(127, 127, 127, 0.05); 75 | border-radius: 12px; 76 | border-width: 1px; 77 | border-style: solid; 78 | border-color: rgba(127, 127, 127, 0.12); 79 | } 80 | form .btn { 81 | margin-right: 0px; 82 | } 83 | `; 84 | -------------------------------------------------------------------------------- /packages/v3/src/css/button.ts: -------------------------------------------------------------------------------- 1 | import { css } from "lit"; 2 | 3 | export default css` 4 | button, 5 | .btn { 6 | cursor: pointer; 7 | border-radius: 4px; 8 | color: rgb(3, 169, 244); 9 | border: none; 10 | background-color: unset; 11 | padding: 8px; 12 | font-weight: 500; 13 | font-size: 12.25px; 14 | letter-spacing: 1.09375px; 15 | text-transform: uppercase; 16 | margin-right: -8px; 17 | } 18 | 19 | button:active, 20 | .btn:active { 21 | background-image: rgba(127, 127, 127, 0.2); 22 | transition-duration: 1s; 23 | } 24 | 25 | button:hover, 26 | .btn:hover { 27 | background-color: rgba(127, 127, 127, 0.2); 28 | transition-duration: 1s; 29 | } 30 | 31 | .abuttonIsState { 32 | background-color: #28a745; 33 | color: white; 34 | border: none; 35 | padding: 10px 20px; 36 | font-size: 16px; 37 | border-radius: 4px; 38 | transition: background-color 0.3s ease; 39 | } 40 | `; 41 | -------------------------------------------------------------------------------- /packages/v3/src/css/esp-entity-table.ts: -------------------------------------------------------------------------------- 1 | import { css } from "lit"; 2 | 3 | export default css` 4 | :host { 5 | position: relative; 6 | } 7 | select { 8 | background-color: inherit; 9 | color: inherit; 10 | width: 100%; 11 | border-radius: 4px; 12 | } 13 | option { 14 | color: currentColor; 15 | background-color: var(--primary-color, currentColor); 16 | } 17 | input[type="range"], 18 | input[type="text"] { 19 | width: calc(100% - 3rem); 20 | height: 0.75rem; 21 | } 22 | .range { 23 | text-align: center; 24 | } 25 | .entity-row { 26 | display: flex; 27 | align-items: center; 28 | flex-direction: row; 29 | transition: all 0.3s ease-out 0s; 30 | min-height: 40px; 31 | position: relative; 32 | } 33 | .entity-row.expanded { 34 | min-height: 240px; 35 | } 36 | .entity-row:nth-child(2n) { 37 | background-color: rgba(90, 90, 90, 0.1); 38 | } 39 | .entity-row iconify-icon { 40 | vertical-align: middle; 41 | } 42 | .entity-row > :nth-child(1) { 43 | flex: 0 0 40px; 44 | color: #44739e; 45 | line-height: 40px; 46 | text-align: center; 47 | } 48 | .entity-row > :nth-child(2) { 49 | flex: 1 1 40%; 50 | margin-left: 16px; 51 | margin-right: 8px; 52 | text-wrap: nowrap; 53 | overflow: hidden; 54 | text-overflow: ellipsis; 55 | min-width: 150px; 56 | } 57 | .entity-row > :nth-child(3) { 58 | flex: 1 1 50%; 59 | margin-right: 8px; 60 | margin-left: 20px; 61 | text-align: right; 62 | display: flex; 63 | justify-content: space-between; 64 | } 65 | .entity-row > :nth-child(3) > :only-child { 66 | margin-left: auto; 67 | } 68 | .binary_sensor_off { 69 | color: rgba(127, 127, 127, 0.7); 70 | } 71 | .singlebutton-row button { 72 | margin: auto; 73 | display: flex; 74 | } 75 | .climate-wrap{ 76 | width: 100%; 77 | margin: 10px 0 10px 0; 78 | } 79 | .climate-row { 80 | width: 100%; 81 | display: inline-flex; 82 | flex-wrap: wrap; 83 | text-align: left; 84 | } 85 | .climate-row > select{ 86 | width: 50%; 87 | } 88 | .climate-row > label{ 89 | align-content: center; 90 | width: 150px; 91 | } 92 | 93 | input[type="color"]::-webkit-color-swatch-wrapper { 94 | padding: 0 !important; 95 | } 96 | `; 97 | -------------------------------------------------------------------------------- /packages/v3/src/css/input.ts: -------------------------------------------------------------------------------- 1 | import { css } from "lit"; 2 | 3 | export default css` 4 | input[type="text"] { 5 | width: 100% !important; 6 | height: 1rem !important; 7 | } 8 | `; 9 | -------------------------------------------------------------------------------- /packages/v3/src/css/reset.ts: -------------------------------------------------------------------------------- 1 | import { css } from "lit"; 2 | 3 | export default css` 4 | :host, button, select, input { 5 | font-family: ui-monospace, system-ui, "Helvetica", "Roboto", 6 | "Oxygen", "Ubuntu", sans-serif; 7 | --primary-color: #03a9f4; 8 | transition: all 350ms !important; 9 | } 10 | `; 11 | -------------------------------------------------------------------------------- /packages/v3/src/css/tab.ts: -------------------------------------------------------------------------------- 1 | import { css } from "lit"; 2 | 3 | export default css` 4 | .tab-header { 5 | display: inline-flex; 6 | max-width:90%; 7 | font-weight: 400; 8 | padding-inline: 1.5em; 9 | padding-top: 0.5em; 10 | padding-bottom: 0.5em; 11 | align-items: center; 12 | border-radius: 12px 12px 0px 0px; 13 | background-color: rgba(127, 127, 127, 0.3); 14 | margin-top: 1em; 15 | user-select: none; 16 | } 17 | .tab-container { 18 | border: 2px solid rgba(127, 127, 127, 0.3); 19 | border-radius: 0px 12px 12px 12px; 20 | } 21 | `; 22 | -------------------------------------------------------------------------------- /packages/v3/src/esp-app.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css, PropertyValues, nothing } from "lit"; 2 | import { customElement, state, query } from "lit/decorators.js"; 3 | import { getBasePath } from "./esp-entity-table"; 4 | 5 | import "./esp-entity-table"; 6 | import "./esp-log"; 7 | import "./esp-switch"; 8 | import "./esp-range-slider"; 9 | import "./esp-logo"; 10 | import cssReset from "./css/reset"; 11 | import cssButton from "./css/button"; 12 | import cssApp from "./css/app"; 13 | import cssTab from "./css/tab"; 14 | 15 | window.source = new EventSource(getBasePath() + "/events"); 16 | 17 | interface Config { 18 | ota: boolean; 19 | log: boolean; 20 | title: string; 21 | comment: string; 22 | } 23 | 24 | function getRelativeTime(diff: number) { 25 | const mark = Math.sign(diff); 26 | 27 | if (diff === 0) return new Intl.RelativeTimeFormat("en").format(0, "second"); 28 | 29 | const times = [ 30 | { type: "year", seconds: 12 * 30 * 24 * 60 * 60 * 1000 }, 31 | { type: "month", seconds: 30 * 24 * 60 * 60 * 1000 }, 32 | { type: "week", seconds: 7 * 24 * 60 * 60 * 1000 }, 33 | { type: "day", seconds: 24 * 60 * 60 * 1000 }, 34 | { type: "hour", seconds: 60 * 60 * 1000 }, 35 | { type: "minute", seconds: 60 * 1000 }, 36 | { type: "second", seconds: 1000 }, 37 | ]; 38 | 39 | let result = ""; 40 | const timeformat = new Intl.RelativeTimeFormat("en"); 41 | let count = 0; 42 | for (let t of times) { 43 | const segment = Math.trunc(Math.abs(diff / t.seconds)); 44 | if (segment > 0) { 45 | const part = timeformat.format( 46 | segment * mark, 47 | t.type as Intl.RelativeTimeFormatUnit 48 | ); 49 | diff -= segment * t.seconds * mark; 50 | // remove "ago" from the first segment - if not the only one 51 | result += 52 | count === 0 && t.type != "second" ? part.replace(" ago", " ") : part; 53 | if (count++ >= 1) break; // do not display detail after two segments 54 | } 55 | } 56 | return result; 57 | } 58 | 59 | @customElement("esp-app") 60 | export default class EspApp extends LitElement { 61 | @state() scheme: string = ""; 62 | @state() ping: number = 0; 63 | @state() connected: boolean = true; 64 | @state() lastUpdate: number = 0; 65 | @query("#beat") 66 | beat!: HTMLSpanElement; 67 | 68 | version: String = import.meta.env.PACKAGE_VERSION; 69 | config: Config = { ota: false, log: true, title: "", comment: "" }; 70 | 71 | darkQuery: MediaQueryList = window.matchMedia("(prefers-color-scheme: dark)"); 72 | 73 | frames = [{}, { color: "rgba(0, 196, 21, 0.75)" }, {}]; 74 | 75 | constructor() { 76 | super(); 77 | const conf = document.querySelector("script#config"); 78 | if (conf) this.setConfig(JSON.parse(conf.innerText)); 79 | } 80 | 81 | setConfig(config: any) { 82 | if (!("log" in config)) { 83 | config.log = this.config.log; 84 | } 85 | this.config = config; 86 | 87 | document.title = config.title; 88 | document.documentElement.lang = config.lang; 89 | } 90 | 91 | firstUpdated(changedProperties: PropertyValues) { 92 | super.firstUpdated(changedProperties); 93 | document.getElementsByTagName("head")[0].innerHTML += 94 | ''; 95 | const l = document.querySelector("link[rel~='icon']"); // Set favicon to house 96 | l.href = 97 | 'data:image/svg+xml,'; 98 | this.scheme = this.schemeDefault(); 99 | window.source.addEventListener("ping", (e: MessageEvent) => { 100 | if (e.data?.length) { 101 | this.setConfig(JSON.parse(e.data)); 102 | this.requestUpdate(); 103 | } 104 | this._updateUptime(e); 105 | this.lastUpdate = Date.now(); 106 | }); 107 | window.source.addEventListener("log", (e: MessageEvent) => { 108 | this._updateUptime(e); 109 | this.lastUpdate = Date.now(); 110 | }); 111 | window.source.addEventListener("state", (e: MessageEvent) => { 112 | this.lastUpdate = Date.now(); 113 | }); 114 | window.source.addEventListener("error", (e: Event) => { 115 | console.dir(e); 116 | //console.log("Lost event stream!") 117 | this.connected = false; 118 | this.requestUpdate(); 119 | }); 120 | setInterval(() => { 121 | this.connected = !!this.ping && Date.now() - this.lastUpdate < 15000; 122 | }, 5000); 123 | document.addEventListener('entity-tab-header-double-clicked', (e) => { 124 | const mainElement = this.shadowRoot?.querySelector('main.flex-grid-half'); 125 | mainElement?.classList.toggle('expanded_entity'); 126 | }); 127 | document.addEventListener('log-tab-header-double-clicked', (e) => { 128 | const mainElement = this.shadowRoot?.querySelector('main.flex-grid-half'); 129 | mainElement?.classList.toggle('expanded_logs'); 130 | }); 131 | } 132 | 133 | schemeDefault() { 134 | return this.darkQuery.matches ? "dark" : "light"; 135 | } 136 | 137 | updated(changedProperties: Map) { 138 | super.updated(changedProperties); 139 | if (changedProperties.has("scheme")) { 140 | let el = document.documentElement; 141 | document.documentElement.style.setProperty("color-scheme", this.scheme); 142 | } 143 | if (changedProperties.has("ping")) { 144 | if (!!this.ping) this.beat.animate(this.frames, 1000); 145 | } 146 | } 147 | 148 | uptime() { 149 | return `${getRelativeTime(-this.ping | 0)}`; 150 | } 151 | 152 | renderOta() { 153 | if (this.config.ota) { 154 | let basePath = getBasePath(); 155 | return html`
OTA Update
156 |
162 | 163 | 164 |
`; 165 | } 166 | } 167 | 168 | renderLog() { 169 | return this.config.log 170 | ? html`
174 | 175 |
` 176 | : nothing; 177 | } 178 | 179 | renderTitle() { 180 | return html` 181 |

${this.config.title || html` `}

182 |
183 | ${[this.config.comment, `started ${this.uptime()}`] 184 | .filter((n) => n) 185 | .map((e) => `${e}`) 186 | .join(" · ")} 187 |
188 | `; 189 | } 190 | 191 | render() { 192 | return html` 193 |
194 | 197 | 203 | 210 | 214 | 215 | ${this.renderTitle()} 216 |
217 |
218 |
222 | 223 | ${this.renderOta()} 224 |
225 | ${this.renderLog()} 226 |
227 | `; 228 | } 229 | 230 | private _updateUptime(e: MessageEvent) { 231 | if (e.lastEventId) { 232 | this.ping = parseInt(e.lastEventId); 233 | this.connected = true; 234 | this.requestUpdate(); 235 | } 236 | } 237 | 238 | static get styles() { 239 | return [cssReset, cssButton, cssApp, cssTab]; 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /packages/v3/src/esp-entity-chart.ts: -------------------------------------------------------------------------------- 1 | import { html, css, LitElement, TemplateResult, nothing } from "lit"; 2 | import { customElement, state, property } from "lit/decorators.js"; 3 | import { 4 | Chart, 5 | Colors, 6 | LineController, 7 | CategoryScale, 8 | LinearScale, 9 | PointElement, 10 | LineElement, 11 | } from "chart.js"; 12 | 13 | Chart.register( 14 | Colors, 15 | LineController, 16 | CategoryScale, 17 | LinearScale, 18 | PointElement, 19 | LineElement 20 | ); 21 | 22 | @customElement("esp-entity-chart") 23 | export class ChartElement extends LitElement { 24 | @property({ type: Array }) chartdata = []; 25 | private chartSubComponent: Chart; 26 | 27 | constructor() { 28 | super(); 29 | } 30 | 31 | updated(changedProperties: Map) { 32 | super.updated(changedProperties); 33 | if (changedProperties.has("chartdata")) { 34 | this.chartSubComponent.data.datasets[0].data = this.chartdata; 35 | this.chartSubComponent.data.labels = this.chartdata; 36 | this.chartSubComponent?.update(); 37 | } 38 | } 39 | 40 | firstUpdated() { 41 | const ctx = this.renderRoot.querySelector("canvas").getContext("2d"); 42 | this.chartSubComponent = new Chart(ctx, { 43 | type: "line", 44 | data: { 45 | labels: this.chartdata, 46 | datasets: [ 47 | { 48 | data: this.chartdata, 49 | borderWidth: 1, 50 | tension: 0.3, 51 | }, 52 | ], 53 | }, 54 | options: { 55 | animation: { duration: 0 }, 56 | plugins: { legend: { display: false } }, 57 | scales: { x: { display: false }, y: { position: "right" } }, 58 | responsive: true, 59 | maintainAspectRatio: false, 60 | }, 61 | }); 62 | this.updateStylesIfExpanded(); 63 | } 64 | // since the :host-context(.expanded) selector is not supported in Safari and Firefox we need to use JS to apply styles 65 | // whether the parent element is expanded or not 66 | updateStylesIfExpanded() { 67 | const parentElement = this.parentElement; 68 | const expandedClass = "expanded"; 69 | 70 | const applyStyles = () => { 71 | if (parentElement && parentElement.classList.contains(expandedClass)) { 72 | this.style.height = "240px"; 73 | this.style.opacity = "0.5"; 74 | } else { 75 | this.style.height = "42px"; 76 | this.style.opacity = "0.1"; 77 | } 78 | }; 79 | 80 | applyStyles(); 81 | 82 | // Observe class changes 83 | const observer = new MutationObserver(applyStyles); 84 | if (parentElement) 85 | observer.observe(parentElement, { 86 | attributes: true, 87 | attributeFilter: ["class"], 88 | }); 89 | } 90 | 91 | static get styles() { 92 | return css` 93 | :host { 94 | position: absolute; 95 | left: 24px; 96 | height: 42px; 97 | width: calc(100% - 42px); 98 | z-index: -100; 99 | } 100 | `; 101 | } 102 | 103 | render() { 104 | return html``; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /packages/v3/src/esp-entity-table.ts: -------------------------------------------------------------------------------- 1 | import { html, css, LitElement, TemplateResult, nothing } from "lit"; 2 | import { customElement, state, property } from "lit/decorators.js"; 3 | import cssReset from "./css/reset"; 4 | import cssButton from "./css/button"; 5 | import cssInput from "./css/input"; 6 | import cssEntityTable from "./css/esp-entity-table"; 7 | import cssTab from "./css/tab"; 8 | import "./esp-entity-chart"; 9 | import "iconify-icon"; 10 | 11 | interface entityConfig { 12 | unique_id: string; 13 | sorting_weight: number; 14 | sorting_group?: string; 15 | domain: string; 16 | id: string; 17 | state: string; 18 | detail: string; 19 | value: string; 20 | name: string; 21 | entity_category?: number; 22 | when: string; 23 | icon?: string; 24 | option?: string[]; 25 | assumed_state?: boolean; 26 | brightness?: number; 27 | color_mode?: string; 28 | color: object; 29 | target_temperature?: number; 30 | target_temperature_low?: number; 31 | target_temperature_high?: number; 32 | min_temp?: number; 33 | max_temp?: number; 34 | min_value?: string; 35 | max_value?: string; 36 | step?: number; 37 | min_length?: number; 38 | max_length?: number; 39 | pattern?: string; 40 | current_temperature?: number; 41 | modes?: number[]; 42 | mode?: number; 43 | speed_count?: number; 44 | speed_level?: number; 45 | speed: string; 46 | effects?: string[]; 47 | effect?: string; 48 | has_action?: boolean; 49 | value_numeric_history: number[]; 50 | uom?: string; 51 | is_disabled_by_default?: boolean; 52 | } 53 | 54 | interface groupConfig { 55 | name: string; 56 | sorting_weight: number; 57 | } 58 | 59 | export const stateOn = "ON"; 60 | export const stateOff = "OFF"; 61 | 62 | export function getBasePath() { 63 | let str = window.location.pathname; 64 | return str.endsWith("/") ? str.slice(0, -1) : str; 65 | } 66 | 67 | interface RestAction { 68 | restAction(entity?: entityConfig, action?: string): void; 69 | } 70 | 71 | @customElement("esp-entity-table") 72 | export class EntityTable extends LitElement implements RestAction { 73 | @state() entities: entityConfig[] = []; 74 | @state() has_controls: boolean = false; 75 | @state() show_all: boolean = false; 76 | 77 | private _actionRenderer = new ActionRenderer(); 78 | private _basePath = getBasePath(); 79 | private groups: groupConfig[] = [] 80 | private static ENTITY_UNDEFINED = "States"; 81 | private static ENTITY_CATEGORIES = [ 82 | "Sensor and Control", 83 | "Configuration", 84 | "Diagnostic", 85 | ]; 86 | 87 | private _unknown_state_events: {[key: string]: number} = {}; 88 | 89 | connectedCallback() { 90 | super.connectedCallback(); 91 | 92 | window.source?.addEventListener('state', (e: Event) => { 93 | const messageEvent = e as MessageEvent; 94 | const data = JSON.parse(messageEvent.data); 95 | let idx = this.entities.findIndex((x) => x.unique_id === data.id); 96 | if (idx != -1 && data.id) { 97 | if (typeof data.value === 'number') { 98 | let history = [...this.entities[idx].value_numeric_history]; 99 | history.push(data.value); 100 | this.entities[idx].value_numeric_history = history.splice(-50); 101 | } 102 | 103 | delete data.id; 104 | delete data.domain; 105 | delete data.unique_id; 106 | Object.assign(this.entities[idx], data); 107 | this.requestUpdate(); 108 | } else { 109 | // is it a `detail_all` event already? 110 | if (data?.name) { 111 | this.addEntity(data); 112 | } else { 113 | if (this._unknown_state_events[data.id]) { 114 | this._unknown_state_events[data.id]++; 115 | } else { 116 | this._unknown_state_events[data.id] = 1; 117 | } 118 | // ignore the first few events, maybe the esp will send a detail_all 119 | // event soon 120 | if (this._unknown_state_events[data.id] < 1) { 121 | return; 122 | } 123 | 124 | let parts = data.id.split('-'); 125 | let domain = parts[0]; 126 | let id = parts.slice(1).join('-'); 127 | 128 | fetch(`${this._basePath}/${domain}/${id}?detail=all`, { 129 | method: 'GET', 130 | }) 131 | .then((r) => { 132 | console.log(r); 133 | if (!r.ok) { 134 | throw new Error(`HTTP error! Status: ${r.status}`); 135 | } 136 | return r.json(); 137 | }) 138 | .then((data) => { 139 | console.log(data); 140 | this.addEntity(data); 141 | }) 142 | .catch((error) => { 143 | console.error('Fetch error:', error); 144 | }); 145 | } 146 | } 147 | }); 148 | 149 | window.source?.addEventListener("sorting_group", (e: Event) => { 150 | const messageEvent = e as MessageEvent; 151 | const data = JSON.parse(messageEvent.data); 152 | const groupIndex = this.groups.findIndex((x) => x.name === data.name); 153 | if (groupIndex === -1) { 154 | let group = { 155 | ...data, 156 | } as groupConfig; 157 | this.groups.push(group); 158 | this.groups.sort((a, b) => { 159 | return a.sorting_weight < b.sorting_weight 160 | ? -1 161 | : 1 162 | }); 163 | } 164 | }); 165 | 166 | this.groups = EntityTable.ENTITY_CATEGORIES.map((category, index) => ({ 167 | name: category, 168 | sorting_weight: index 169 | })); 170 | 171 | this.groups.push({ 172 | name: EntityTable.ENTITY_UNDEFINED, 173 | sorting_weight: -1 174 | }); 175 | } 176 | 177 | addEntity(data: any) { 178 | let idx = this.entities.findIndex((x) => x.unique_id === data.id); 179 | if (idx === -1 && data.id) { 180 | // Dynamically add discovered.. 181 | let parts = data.id.split("-"); 182 | let entity = { 183 | ...data, 184 | domain: parts[0], 185 | unique_id: data.id, 186 | id: parts.slice(1).join("-"), 187 | entity_category: data.entity_category, 188 | sorting_group: data.sorting_group ?? (EntityTable.ENTITY_CATEGORIES[parseInt(data.entity_category)] || EntityTable.ENTITY_UNDEFINED), 189 | value_numeric_history: [data.value], 190 | } as entityConfig; 191 | entity.has_action = this.hasAction(entity); 192 | if (entity.has_action) { 193 | this.has_controls = true; 194 | } 195 | this.entities.push(entity); 196 | this.entities.sort((a, b) => { 197 | const sortA = a.sorting_weight ?? a.name; 198 | const sortB = b.sorting_weight ?? b.name; 199 | return a.sorting_group < b.sorting_group 200 | ? -1 201 | : a.sorting_group === b.sorting_group 202 | ? sortA === sortB 203 | ? a.name.toLowerCase() < b.name.toLowerCase() 204 | ? -1 205 | : 1 206 | : sortA < sortB 207 | ? -1 208 | : 1 209 | : 1 210 | }); 211 | this.requestUpdate(); 212 | } 213 | 214 | } 215 | 216 | hasAction(entity: entityConfig): boolean { 217 | return `render_${entity.domain}` in this._actionRenderer; 218 | } 219 | 220 | control(entity: entityConfig) { 221 | this._actionRenderer.entity = entity; 222 | this._actionRenderer.actioner = this; 223 | return this._actionRenderer.exec( 224 | `render_${entity.domain}` as ActionRendererMethodKey 225 | ); 226 | } 227 | 228 | restAction(entity: entityConfig, action: string) { 229 | fetch(`${this._basePath}/${entity.domain}/${entity.id}/${action}`, { 230 | method: "POST", 231 | headers:{ 232 | 'Content-Type': 'application/x-www-form-urlencoded' 233 | }, 234 | }).then((r) => { 235 | console.log(r); 236 | }); 237 | } 238 | 239 | renderShowAll() { 240 | if ( 241 | !this.show_all && 242 | this.entities.find((elem) => elem.is_disabled_by_default) 243 | ) { 244 | return html`
245 | 251 |
`; 252 | } 253 | } 254 | 255 | render() { 256 | const groupBy = (xs: Array, key: string): Map> => { 257 | const groupedMap = xs.reduce(function (rv, x) { 258 | ( 259 | rv.get(x[key]) || 260 | (() => { 261 | let tmp: Array = []; 262 | rv.set(x[key], tmp); 263 | return tmp; 264 | })() 265 | ).push(x); 266 | return rv; 267 | }, new Map>()); 268 | 269 | const sortedGroupedMap = new Map>(); 270 | for (const group of this.groups) { 271 | const groupName = group.name; 272 | if (groupedMap.has(groupName)) { 273 | sortedGroupedMap.set(groupName, groupedMap.get(groupName) || []); 274 | } 275 | } 276 | 277 | return sortedGroupedMap; 278 | } 279 | 280 | const entities = this.show_all 281 | ? this.entities 282 | : this.entities.filter((elem) => !elem.is_disabled_by_default); 283 | const grouped = groupBy(entities, "sorting_group"); 284 | const elems = Array.from(grouped, ([name, value]) => ({ name, value })); 285 | return html` 286 |
287 | ${elems.map( 288 | (group) => html` 289 |
293 | ${group.name || 294 | EntityTable.ENTITY_UNDEFINED} 295 |
296 |
297 | ${group.value.map( 298 | (component, idx) => html` 299 |
304 |
305 | ${component.icon 306 | ? html`` 310 | : nothing} 311 |
312 |
${component.name}
313 |
314 | ${this.has_controls && component.has_action 315 | ? this.control(component) 316 | : html`
${component.state}
`} 317 |
318 | ${component.domain === "sensor" 319 | ? html`` 322 | : nothing} 323 |
324 | ` 325 | )} 326 |
327 | ` 328 | )} 329 | ${this.renderShowAll()} 330 |
331 | `; 332 | } 333 | 334 | static get styles() { 335 | return [cssReset, cssButton, cssInput, cssEntityTable, cssTab]; 336 | } 337 | 338 | _handleEntityRowClick(e: any) { 339 | if (e?.currentTarget?.domain === "sensor") { 340 | if (!e?.ctrlKey) e.stopPropagation(); 341 | e?.currentTarget?.classList.toggle( 342 | "expanded", 343 | !e.ctrlKey ? undefined : true 344 | ); 345 | } 346 | } 347 | _handleTabHeaderDblClick(e: Event) { 348 | const doubleClickEvent = new CustomEvent('entity-tab-header-double-clicked', { 349 | bubbles: true, 350 | composed: true, 351 | }); 352 | e.target?.dispatchEvent(doubleClickEvent); 353 | } 354 | } 355 | 356 | 357 | type ActionRendererNonCallable = "entity" | "actioner" | "exec"; 358 | type ActionRendererMethodKey = keyof Omit< 359 | ActionRenderer, 360 | ActionRendererNonCallable 361 | >; 362 | 363 | class ActionRenderer { 364 | public entity?: entityConfig; 365 | public actioner?: RestAction; 366 | 367 | exec(method: ActionRendererMethodKey) { 368 | if (!this[method] || typeof this[method] !== "function") { 369 | console.log(`ActionRenderer.${method} is not callable`); 370 | return; 371 | } 372 | return this[method](); 373 | } 374 | 375 | private _actionButton(entity: entityConfig, label: string, action: string, isCurrentState: boolean = false) { 376 | if (!entity) return; 377 | let a = action || label.toLowerCase(); 378 | return html``; 385 | } 386 | 387 | private _datetime( 388 | entity: entityConfig, 389 | type: string, 390 | action: string, 391 | opt: string, 392 | value: string, 393 | ) { 394 | return html` 395 | 408 | `; 409 | } 410 | 411 | private _switch(entity: entityConfig) { 412 | return html``; 420 | } 421 | 422 | private _select( 423 | entity: entityConfig, 424 | action: string, 425 | opt: string, 426 | options: string[] | number[], 427 | val: string | number | undefined 428 | ) { 429 | return html``; 447 | } 448 | 449 | private _range( 450 | entity: entityConfig, 451 | action: string, 452 | opt: string, 453 | value: string | number, 454 | min?: string | undefined, 455 | max?: string | undefined, 456 | step = 1 457 | ) { 458 | if(entity.mode == 1) { 459 | return html`
460 | 461 | 474 | 475 |
`; 476 | } else { 477 | return html` 478 | `; 489 | } 490 | 491 | } 492 | 493 | private _textinput( 494 | entity: entityConfig, 495 | action: string, 496 | opt: string, 497 | value: string | number, 498 | min: number | undefined, 499 | max: number | undefined, 500 | pattern: string | undefined 501 | ) { 502 | return html` 503 | 519 | `; 520 | } 521 | 522 | private _colorpicker(entity: entityConfig, action: string, value: any) { 523 | function u16tohex(d: number) { 524 | return Number(d).toString(16).padStart(2, "0"); 525 | } 526 | function rgb_to_str(rgbhex: string) { 527 | const rgb = rgbhex 528 | .match(/[0-9a-f]{2}/gi) 529 | ?.map((x) => parseInt(x, 16)) || [0, 0, 0]; 530 | return `r=${rgb[0]}&g=${rgb[1]}&b=${rgb[2]}`; 531 | } 532 | 533 | return html`
534 | 544 |
`; 545 | } 546 | 547 | render_binary_sensor() { 548 | if (!this.entity) return; 549 | const isOn = this.entity.state == stateOn; 550 | return html``; 555 | } 556 | 557 | render_date() { 558 | if (!this.entity) return; 559 | return html` 560 | ${this._datetime( 561 | this.entity, 562 | "date", 563 | "set", 564 | "value", 565 | this.entity.value, 566 | )} 567 | `; 568 | } 569 | 570 | render_time() { 571 | if (!this.entity) return; 572 | return html` 573 | ${this._datetime( 574 | this.entity, 575 | "time", 576 | "set", 577 | "value", 578 | this.entity.value, 579 | )} 580 | `; 581 | } 582 | 583 | render_datetime() { 584 | if (!this.entity) return; 585 | return html` 586 | ${this._datetime( 587 | this.entity, 588 | "datetime-local", 589 | "set", 590 | "value", 591 | this.entity.value, 592 | )} 593 | `; 594 | } 595 | 596 | render_switch() { 597 | if (!this.entity) return; 598 | if (this.entity.assumed_state) 599 | return html`${this._actionButton(this.entity, "❌", "turn_off")} 600 | ${this._actionButton(this.entity, "✔️", "turn_on")}`; 601 | else return this._switch(this.entity); 602 | } 603 | 604 | render_fan() { 605 | if (!this.entity) return; 606 | return [ 607 | this.entity.speed, 608 | " ", 609 | this.entity.speed_level, 610 | this._switch(this.entity), 611 | this.entity.speed_count 612 | ? this._range( 613 | this.entity, 614 | `turn_${this.entity.state.toLowerCase()}`, 615 | "speed_level", 616 | this.entity.speed_level ? this.entity.speed_level : 0, 617 | 0, 618 | this.entity.speed_count, 619 | 1 620 | ) 621 | : "", 622 | ]; 623 | } 624 | 625 | render_light() { 626 | if (!this.entity) return; 627 | return [ 628 | html`
630 | ${this._switch(this.entity)} 631 | ${this.entity.brightness 632 | ? this._range( 633 | this.entity, 634 | "turn_on", 635 | "brightness", 636 | this.entity.brightness, 637 | 0, 638 | 255, 639 | 1 640 | ) 641 | : ""} 642 | ${this.entity.color_mode === "rgb" || this.entity.color_mode === "rgbw" 643 | ? this._colorpicker(this.entity, "turn_on", this.entity?.color) 644 | : ""} 645 | ${this.entity.effects?.filter((v) => v != "None").length 646 | ? this._select( 647 | this.entity, 648 | "turn_on", 649 | "effect", 650 | this.entity.effects || [], 651 | this.entity.effect 652 | ) 653 | : ""} 654 |
`, 655 | ]; 656 | } 657 | 658 | render_lock() { 659 | if (!this.entity) return; 660 | return html`${this._actionButton(this.entity, "🔐", "lock", this.entity.state === "LOCKED")} 661 | ${this._actionButton(this.entity, "🔓", "unlock", this.entity.state === "UNLOCKED")} 662 | ${this._actionButton(this.entity, "↑", "open")} `; 663 | } 664 | 665 | render_cover() { 666 | if (!this.entity) return; 667 | return html`${this._actionButton(this.entity, "↑", "open", this.entity.state === "OPEN")} 668 | ${this._actionButton(this.entity, "☐", "stop")} 669 | ${this._actionButton(this.entity, "↓", "close", this.entity.state === "CLOSED")}`; 670 | } 671 | 672 | render_button() { 673 | if (!this.entity) return; 674 | return html`${this._actionButton(this.entity, "PRESS", "press")}`; 675 | } 676 | 677 | render_select() { 678 | if (!this.entity) return; 679 | return this._select( 680 | this.entity, 681 | "set", 682 | "option", 683 | this.entity.option || [], 684 | this.entity.value 685 | ); 686 | } 687 | 688 | render_number() { 689 | if (!this.entity) return; 690 | return html` 691 | ${this._range( 692 | this.entity, 693 | "set", 694 | "value", 695 | this.entity.value, 696 | this.entity.min_value, 697 | this.entity.max_value, 698 | this.entity.step 699 | )} 700 | ${this.entity.uom} 701 | `; 702 | } 703 | 704 | render_text() { 705 | if (!this.entity) return; 706 | return this._textinput( 707 | this.entity, 708 | "set", 709 | "value", 710 | this.entity.value, 711 | this.entity.min_length, 712 | this.entity.max_length, 713 | this.entity.pattern 714 | ); 715 | } 716 | 717 | render_climate() { 718 | if (!this.entity) return; 719 | let target_temp_slider, target_temp_label, target_temp; 720 | let current_temp = html`
721 | 722 |
`; 723 | 724 | if ( 725 | this.entity.target_temperature_low !== undefined && 726 | this.entity.target_temperature_high !== undefined 727 | ) { 728 | target_temp = html` 729 |
730 | 731 | ${this._range( 732 | this.entity, 733 | "set", 734 | "target_temperature_low", 735 | this.entity.target_temperature_low, 736 | this.entity.min_temp, 737 | this.entity.max_temp, 738 | this.entity.step 739 | )} 740 |
741 |
742 | 743 | ${this._range( 744 | this.entity, 745 | "set", 746 | "target_temperature_high", 747 | this.entity.target_temperature_high, 748 | this.entity.min_temp, 749 | this.entity.max_temp, 750 | this.entity.step 751 | )} 752 |
`; 753 | } else { 754 | target_temp = html` 755 |
756 | 757 | ${this._range( 758 | this.entity, 759 | "set", 760 | "target_temperature", 761 | this.entity.target_temperature!!, 762 | this.entity.min_temp, 763 | this.entity.max_temp, 764 | this.entity.step 765 | )} 766 |
`; 767 | } 768 | let modes = html``; 769 | if ((this.entity.modes ? this.entity.modes.length : 0) > 0) { 770 | modes = html` 771 |
772 | 773 | ${this._select( 774 | this.entity, 775 | "set", 776 | "mode", 777 | this.entity.modes || [], 778 | this.entity.mode || "" 779 | )} 780 |
`; 781 | } 782 | return html` 783 |
784 | ${current_temp} ${target_temp} ${modes} 785 |
786 | `; 787 | } 788 | render_valve() { 789 | if (!this.entity) return; 790 | return html`${this._actionButton(this.entity, "OPEN", "open", this.entity.state === "OPEN")} 791 | ${this._actionButton(this.entity, "☐", "stop")} 792 | ${this._actionButton(this.entity, "CLOSE", "close", this.entity.state === "CLOSED")}`; 793 | } 794 | } 795 | -------------------------------------------------------------------------------- /packages/v3/src/esp-log.ts: -------------------------------------------------------------------------------- 1 | import { html, css, LitElement } from "lit"; 2 | import { customElement, property, state } from "lit/decorators.js"; 3 | import cssTab from "./css/tab"; 4 | 5 | interface recordConfig { 6 | type: string; 7 | level: string; 8 | tag: string; 9 | detail: string; 10 | when: string; 11 | } 12 | 13 | @customElement("esp-log") 14 | export class DebugLog extends LitElement { 15 | @property({ type: Number }) rows = 10; 16 | @property({ type: String }) scheme = ""; 17 | @state() logs: recordConfig[] = []; 18 | 19 | constructor() { 20 | super(); 21 | } 22 | 23 | connectedCallback() { 24 | super.connectedCallback(); 25 | window.source?.addEventListener("log", (e: Event) => { 26 | const messageEvent = e as MessageEvent; 27 | const d: String = messageEvent.data; 28 | 29 | const types: Record = { 30 | "": "e", 31 | "": "w", 32 | "": "i", 33 | "": "c", 34 | "": "d", 35 | "": "v", 36 | }; 37 | 38 | // Extract the type from the color code 39 | const type = types[d.slice(0, 7)]; 40 | if (!type) { 41 | // No color code, skip 42 | return; 43 | } 44 | 45 | // Extract content without color codes and ANSI termination 46 | const content = d.slice(7, d.length - 4); 47 | 48 | // Split by newlines to handle multi-line messages 49 | const lines = content.split('\n'); 50 | 51 | // Process the first line to extract metadata 52 | const firstLine = lines[0]; 53 | const parts = firstLine.slice(3).split(":"); 54 | const tag = parts.slice(0, 2).join(":"); 55 | const firstDetail = firstLine.slice(5 + tag.length); 56 | const level = firstLine.slice(0, 3); 57 | const when = new Date().toTimeString().split(" ")[0]; 58 | 59 | // Create a log record for each line 60 | lines.forEach((line, index) => { 61 | const record = { 62 | type: type, 63 | level: level, 64 | tag: tag, 65 | detail: index === 0 ? firstDetail : line, 66 | when: when, 67 | } as recordConfig; 68 | this.logs.push(record); 69 | }); 70 | 71 | this.logs = this.logs.slice(-this.rows); 72 | }); 73 | } 74 | 75 | render() { 76 | return html` 77 |
81 | Debug Log 82 |
83 |
84 |
85 |
86 |
Time
87 |
Level
88 |
Tag
89 |
Message
90 |
91 |
92 | ${this.logs.map( 93 | (log: recordConfig) => 94 | html` 95 |
96 |
${log.when}
97 |
${log.level}
98 |
${log.tag}
99 |
${log.detail}
100 | 101 | ` 102 | )} 103 |
104 |
105 |
106 | `; 107 | } 108 | 109 | _handleTabHeaderDblClick(e: Event) { 110 | const doubleClickEvent = new CustomEvent('log-tab-header-double-clicked', { 111 | bubbles: true, 112 | composed: true, 113 | }); 114 | e.target?.dispatchEvent(doubleClickEvent); 115 | } 116 | 117 | static get styles() { 118 | return [ 119 | cssTab, 120 | css` 121 | .thead, 122 | .tbody .trow:nth-child(2n) { 123 | background-color: rgba(127, 127, 127, 0.05); 124 | } 125 | .trow div { 126 | font-family: monospace; 127 | width: 100%; 128 | line-height: 1.2rem; 129 | } 130 | .trow { 131 | display: flex; 132 | } 133 | .thead { 134 | line-height: 1rem; 135 | } 136 | .thead .trow { 137 | text-align: left; 138 | padding: 0.25rem 0.5rem; 139 | } 140 | .trow { 141 | display: flex; 142 | } 143 | .trow > div { 144 | align-self: flex-start; 145 | padding-right: 0.25em; 146 | flex: 2 0; 147 | min-width: 70px; 148 | 149 | } 150 | .trow > div:nth-child(2) { 151 | flex: 1 0; 152 | overflow: hidden; 153 | text-overflow: ellipsis; 154 | max-width: 40px; 155 | } 156 | .trow > div:nth-child(3) { 157 | flex: 3 0; 158 | overflow: hidden; 159 | text-overflow: ellipsis; 160 | } 161 | .trow > div:last-child { 162 | flex: 15 0; 163 | padding-right: 0em; 164 | overflow: hidden; 165 | text-overflow: ellipsis; 166 | } 167 | pre { 168 | margin: 0; 169 | } 170 | .v { 171 | color: #888888; 172 | } 173 | .d { 174 | color: #00dddd; 175 | } 176 | .c { 177 | color: magenta; 178 | } 179 | .i { 180 | color: limegreen; 181 | } 182 | .w { 183 | color: yellow; 184 | } 185 | .e { 186 | color: red; 187 | font-weight: bold; 188 | } 189 | .logs[color-scheme="light"] { 190 | font-weight: bold; 191 | } 192 | .logs[color-scheme="light"] .w { 193 | color: #cccc00; 194 | } 195 | .logs[color-scheme="dark"] .d { 196 | color: #00aaaa; 197 | } 198 | .logs { 199 | overflow-x: auto; 200 | border-radius: 12px; 201 | border-width: 1px; 202 | border-style: solid; 203 | border-color: rgba(127, 127, 127, 0.12); 204 | transition: all 0.3s ease-out 0s; 205 | font-size: 14px; 206 | padding: 16px; 207 | } 208 | @media (max-width: 1024px) { 209 | .trow > div:nth-child(2) { 210 | display: none !important; 211 | } 212 | } 213 | `, 214 | ]; 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /packages/v3/src/esp-logo.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, svg } from "lit"; 2 | import { customElement } from "lit/decorators.js"; 3 | 4 | import logo from "/logo.svg?raw"; 5 | 6 | @customElement("esp-logo") 7 | export default class EspLogo extends LitElement { 8 | render() { 9 | return svg([logo]); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/v3/src/esp-range-slider.ts: -------------------------------------------------------------------------------- 1 | import { html, css, LitElement } from "lit"; 2 | import { customElement, property } from "lit/decorators.js"; 3 | import cssReset from "./css/reset"; 4 | 5 | const inputRangeID: string = "range"; 6 | const currentValueID: string = "rangeValue"; 7 | const pressTimeToShowPopup = 500; 8 | 9 | @customElement("esp-range-slider") 10 | export class EspRangeSlider extends LitElement { 11 | private inputRange: HTMLInputElement | null = null; 12 | private currentValue: HTMLInputElement | null = null; 13 | 14 | private longPressTimer: ReturnType | null = null; 15 | private isPopupInputVisible: boolean = false; 16 | 17 | @property({ type: String }) value = 0; 18 | @property({ type: String }) min = 0; 19 | @property({ type: String }) max = 0; 20 | @property({ type: String }) step = 0; 21 | @property({ type: String }) name = ""; 22 | 23 | protected firstUpdated( 24 | _changedProperties: Map 25 | ): void { 26 | this.inputRange = this.shadowRoot?.getElementById( 27 | inputRangeID 28 | ) as HTMLInputElement; 29 | 30 | this.currentValue = this.shadowRoot?.getElementById( 31 | currentValueID 32 | ) as HTMLInputElement; 33 | document.addEventListener('mousedown', (event) => { 34 | if(!document.querySelector('.popup-number-input')) { 35 | return; 36 | } 37 | const isClickedOutside = !document.querySelector('.popup-number-input')?.contains(event.target as Node); 38 | if (isClickedOutside && this.isPopupInputVisible) { 39 | this.deletePopupInput(); 40 | } 41 | }); 42 | } 43 | 44 | protected updated(): void { 45 | this.updateCurrentValueOverlay(); 46 | } 47 | 48 | onMouseDownCurrentValue(event: MouseEvent): void { 49 | this.longPressTimer = setTimeout(() => { 50 | this.showPopupInput(event.pageX, event.pageY); 51 | }, pressTimeToShowPopup); 52 | } 53 | 54 | onMouseUpCurrentValue(event: MouseEvent): void { 55 | if (this.longPressTimer && !this.isPopupInputVisible) { 56 | clearTimeout(this.longPressTimer); 57 | this.longPressTimer = null; 58 | } 59 | } 60 | 61 | onTouchStartCurrentValue(event: TouchEvent): void { 62 | this.longPressTimer = setTimeout(() => { 63 | this.showPopupInput(event.touches[0].pageX,event.touches[0].pageY); 64 | }, pressTimeToShowPopup); 65 | } 66 | 67 | onTouchEndCurrentValue(event: TouchEvent): void { 68 | if (this.longPressTimer && !this.isPopupInputVisible) { 69 | clearTimeout(this.longPressTimer); 70 | this.longPressTimer = null; 71 | } 72 | } 73 | 74 | deletePopupInput(): void { 75 | const popupInputElement = document.querySelector('.popup-number-input'); 76 | if (popupInputElement) { 77 | popupInputElement.remove(); 78 | } 79 | this.isPopupInputVisible = false; 80 | } 81 | 82 | showPopupInput(x: number, y: number): void { 83 | const popupInputElement = document.createElement('input'); 84 | popupInputElement.type = 'number'; 85 | popupInputElement.value = this.inputRange.value; 86 | popupInputElement.min = this.inputRange.min; 87 | popupInputElement.max = this.inputRange.max; 88 | popupInputElement.step = this.inputRange.step; 89 | popupInputElement.classList.add('popup-number-input'); 90 | 91 | const styles = ` 92 | position: absolute; 93 | left: ${x}px; 94 | top: ${y}px; 95 | width: 50px; 96 | -webkit-appearance: none; 97 | margin: 0; 98 | `; 99 | popupInputElement.setAttribute('style', styles); 100 | document.body.appendChild(popupInputElement); 101 | 102 | popupInputElement.addEventListener('contextmenu', (event) => { 103 | event.preventDefault(); 104 | }); 105 | 106 | popupInputElement.addEventListener('change', (ev: Event) =>{ 107 | let input = ev.target as HTMLInputElement; 108 | this.inputRange.value = input?.value; 109 | 110 | var event = new Event('input'); 111 | this.inputRange?.dispatchEvent(event); 112 | var event = new Event('change'); 113 | this.inputRange?.dispatchEvent(event); 114 | }); 115 | 116 | popupInputElement.addEventListener('keydown', (event) => { 117 | if (event.key === 'Enter') { 118 | this.deletePopupInput(); 119 | } 120 | }); 121 | 122 | popupInputElement.focus(); 123 | this.isPopupInputVisible = true; 124 | } 125 | 126 | updateCurrentValueOverlay(): void { 127 | const newValueAsPercent = Number( (this.inputRange.value - this.inputRange.min) * 100 / (this.inputRange.max - this.inputRange.min) ), 128 | newPosition = 10 - (newValueAsPercent * 0.2); 129 | this.currentValue.innerHTML = `${this.inputRange?.value}`; 130 | this.currentValue.style.left = `calc(${newValueAsPercent}% + (${newPosition}px))`; 131 | 132 | const spanTooltip = this.currentValue?.querySelector('span'); 133 | spanTooltip?.addEventListener('mousedown', this.onMouseDownCurrentValue.bind(this)); 134 | spanTooltip?.addEventListener('mouseup', this.onMouseUpCurrentValue.bind(this)); 135 | spanTooltip?.addEventListener('touchstart', this.onTouchStartCurrentValue.bind(this)); 136 | spanTooltip?.addEventListener('touchend', this.onTouchEndCurrentValue.bind(this)); 137 | 138 | spanTooltip?.addEventListener('contextmenu', (event) => { 139 | event.preventDefault(); 140 | }); 141 | } 142 | 143 | onInputEvent(ev: Event): void { 144 | this.updateCurrentValueOverlay(); 145 | } 146 | 147 | onInputChangeEvent(ev: Event): void { 148 | this.sendState(this.inputRange?.value); 149 | } 150 | 151 | sendState(value: string|undefined): void { 152 | let event = new CustomEvent("state", { 153 | detail: { 154 | state: value, 155 | id: this.id, 156 | }, 157 | }); 158 | this.dispatchEvent(event); 159 | } 160 | 161 | render() { 162 | return html` 163 |
164 | 165 |
166 |
167 | 178 |
179 | 180 |
181 | `; 182 | } 183 | 184 | static get styles() { 185 | return [ 186 | cssReset, 187 | css` 188 | :host { 189 | min-width: 150px; 190 | flex: 1; 191 | } 192 | input[type=range] { 193 | background: transparent; 194 | -webkit-appearance: none; 195 | appearance: none; 196 | margin: 20px 0; 197 | width: 100%; 198 | touch-action: none; 199 | } 200 | input[type=range]:focus { 201 | outline: none; 202 | } 203 | input[type=range]::-webkit-slider-runnable-track { 204 | width: 100%; 205 | height: 4px; 206 | cursor: pointer; 207 | animate: 0.2s; 208 | background: #03a9f4; 209 | border-radius: 25px; 210 | } 211 | input[type=range]::-moz-range-track { 212 | width: 100%; 213 | height: 4px; 214 | cursor: pointer; 215 | animate: 0.2s; 216 | background: #03a9f4; 217 | border-radius: 25px; 218 | } 219 | input[type=range]::-ms-track { 220 | background: transparent; 221 | width: 100%; 222 | height: 4px; 223 | cursor: pointer; 224 | animate: 0.2s; 225 | background: transparent; 226 | border-color: transparent; 227 | color: transparent; 228 | } 229 | input[type=range]::-ms-fill-lower { 230 | background: #03a9f4; 231 | border-radius: 25px; 232 | } 233 | input[type=range]::-ms-fill-upper { 234 | background: #03a9f4; 235 | border-radius: 25px; 236 | } 237 | input[type=range]::-webkit-slider-thumb { 238 | height: 20px; 239 | width: 20px; 240 | border-radius: 50%; 241 | background: #fff; 242 | box-shadow: 0 0 4px 0 rgba(0,0,0, 1); 243 | cursor: pointer; 244 | -webkit-appearance: none; 245 | margin-top: -8px; 246 | } 247 | input[type=range]::-moz-range-thumb { 248 | height: 20px; 249 | width: 20px; 250 | border-radius: 50%; 251 | background: #fff; 252 | box-shadow: 0 0 4px 0 rgba(0,0,0, 1); 253 | cursor: pointer; 254 | border: none; 255 | } 256 | input[type=range]::-ms-thumb { 257 | height: 20px; 258 | width: 20px; 259 | border-radius: 50%; 260 | background: #fff; 261 | box-shadow: 0 0 4px 0 rgba(0,0,0, 1); 262 | cursor: pointer; 263 | border: none; 264 | } 265 | input[type=range]:focus::-webkit-slider-runnable-track { 266 | background: #03a9f4; 267 | } 268 | input[type=range]:focus::-moz-range-track { 269 | background: #03a9f4; 270 | } 271 | input[type=range]:focus::-ms-fill-lower { 272 | background: #03a9f4; 273 | } 274 | input[type=range]:focus::-ms-fill-upper { 275 | background: #03a9f4; 276 | } 277 | .range-wrap { 278 | display: flex; 279 | align-items: center; 280 | } 281 | .slider-wrap { 282 | flex-grow: 1; 283 | margin: 0px 15px; 284 | position: relative; 285 | } 286 | .range-value { 287 | position: absolute; 288 | top: -50%; 289 | } 290 | .range-value span { 291 | padding: 0 3px 0 3px; 292 | height: 19px; 293 | line-height: 18px; 294 | text-align: center; 295 | background: #03a9f4; 296 | color: #fff; 297 | font-size: 11px; 298 | display: block; 299 | position: absolute; 300 | left: 50%; 301 | transform: translate(-50%, +80%); 302 | border-radius: 6px; 303 | } 304 | @-moz-document url-prefix() { 305 | .range-value span { 306 | transform: translate(-50%, +150%); 307 | } 308 | } 309 | .range-value span:before { 310 | content: ""; 311 | position: absolute; 312 | width: 0; 313 | height: 0; 314 | border-top: 10px solid #03a9f4; 315 | border-left: 5px solid transparent; 316 | border-right: 5px solid transparent; 317 | top: 100%; 318 | left: 50%; 319 | margin-left: -5px; 320 | margin-top: -1px; 321 | pointer-events: none; 322 | } 323 | `, 324 | ]; 325 | } 326 | } 327 | -------------------------------------------------------------------------------- /packages/v3/src/esp-switch.ts: -------------------------------------------------------------------------------- 1 | import { html, css, LitElement } from "lit"; 2 | import { customElement, property } from "lit/decorators.js"; 3 | import { stateOn, stateOff } from "./esp-entity-table"; 4 | import cssReset from "./css/reset"; 5 | 6 | const checkboxID: string = "checkbox-lever"; 7 | 8 | @customElement("esp-switch") 9 | export class EspSwitch extends LitElement { 10 | private checkbox: HTMLInputElement | null = null; 11 | 12 | // Use arrays - or slots 13 | @property({ type: String }) stateOn = stateOn; 14 | @property({ type: String }) stateOff = stateOff; 15 | @property({ type: String }) state = stateOff; 16 | @property({ type: String }) color = "currentColor"; 17 | @property({ type: Boolean }) disabled = false; 18 | 19 | protected firstUpdated( 20 | _changedProperties: Map 21 | ): void { 22 | this.checkbox = this.shadowRoot?.getElementById( 23 | checkboxID 24 | ) as HTMLInputElement; 25 | } 26 | 27 | private isOn(): boolean { 28 | return this.state === this.stateOn; 29 | } 30 | 31 | toggle(ev: Event): void { 32 | const newState = this.isOn() ? this.stateOff : this.stateOn; 33 | let event = new CustomEvent("state", { 34 | detail: { 35 | state: newState, 36 | id: this.id, 37 | }, 38 | }); 39 | this.dispatchEvent(event); 40 | } 41 | 42 | render() { 43 | return html` 44 |
45 | 55 |
56 | `; 57 | } 58 | 59 | static get styles() { 60 | return [ 61 | cssReset, 62 | css` 63 | .sw, 64 | .sw * { 65 | -webkit-tap-highlight-color: transparent; 66 | user-select: none; 67 | cursor: pointer; 68 | } 69 | 70 | input[type="checkbox"] { 71 | opacity: 0; 72 | width: 0; 73 | height: 0; 74 | } 75 | 76 | input[type="checkbox"]:checked + .lever { 77 | background-color: currentColor; 78 | background-image: linear-gradient( 79 | 0deg, 80 | rgba(255, 255, 255, 0.5) 0%, 81 | rgba(255, 255, 255, 0.5) 100% 82 | ); 83 | } 84 | 85 | input[type="checkbox"]:checked + .lever:before, 86 | input[type="checkbox"]:checked + .lever:after { 87 | left: 18px; 88 | } 89 | 90 | input[type="checkbox"]:checked + .lever:after { 91 | background-color: currentColor; 92 | } 93 | 94 | input[type="checkbox"]:not(:checked) + .lever:after { 95 | background-color: rgba(127, 127, 127, 0.5); 96 | } 97 | 98 | .lever { 99 | content: ""; 100 | display: inline-block; 101 | position: relative; 102 | width: 36px; 103 | height: 14px; 104 | background-image: linear-gradient( 105 | 0deg, 106 | rgba(127, 127, 127, 0.5) 0%, 107 | rgba(127, 127, 127, 0.5) 100% 108 | ); 109 | background-color: inherit; 110 | border-radius: 15px; 111 | transition: background 0.3s ease; 112 | vertical-align: middle; 113 | } 114 | 115 | .lever:before, 116 | .lever:after { 117 | content: ""; 118 | position: absolute; 119 | display: inline-block; 120 | width: 20px; 121 | height: 20px; 122 | border-radius: 50%; 123 | left: 0; 124 | top: -3px; 125 | transition: left 0.3s ease, background 0.3s ease, box-shadow 0.1s ease, 126 | transform 0.1s ease; 127 | } 128 | 129 | .lever:before { 130 | background-color: currentColor; 131 | background-image: linear-gradient( 132 | 0deg, 133 | rgba(255, 255, 255, 0.9) 0%, 134 | rgba(255, 255, 255, 0.9) 100% 135 | ); 136 | } 137 | 138 | .lever:after { 139 | background-color: #f1f1f1; 140 | box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.2), 141 | 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 142 | 0px 1px 5px 0px rgba(0, 0, 0, 0.12); 143 | } 144 | 145 | input[type="checkbox"]:checked:not(:disabled) ~ .lever:active::before, 146 | input[type="checkbox"]:checked:not(:disabled).tabbed:focus 147 | ~ .lever::before { 148 | transform: scale(2.4); 149 | background-color: rgba(255, 255, 255, 0.9) 0%; 150 | background-image: linear-gradient( 151 | 0deg, 152 | rgba(255, 255, 255, 0.9) 0%, 153 | rgba(255, 255, 255, 0.9) 100% 154 | ); 155 | } 156 | 157 | input[type="checkbox"]:not(:disabled) ~ .lever:active:before, 158 | input[type="checkbox"]:not(:disabled).tabbed:focus ~ .lever::before { 159 | transform: scale(2.4); 160 | background-color: rgba(0, 0, 0, 0.08); 161 | } 162 | 163 | input[type="checkbox"][disabled] + .lever { 164 | cursor: default; 165 | background-color: rgba(0, 0, 0, 0.12); 166 | } 167 | 168 | input[type="checkbox"][disabled] + .lever:after, 169 | input[type="checkbox"][disabled]:checked + .lever:after { 170 | background-color: #949494; 171 | } 172 | `, 173 | ]; 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /packages/v3/src/main.css: -------------------------------------------------------------------------------- 1 | /* First, declare your dark mode colors */ 2 | :root { 3 | --c-bg: #fff; 4 | --c-text: #000; 5 | --c-primary: #26a69a; 6 | --color:0, 100%; 7 | --l:50%; 8 | --color-primary: #26a69a; 9 | 10 | --color-primary-darker: hsl(var(--color),calc(var(--l) - 5%)); 11 | --color-primary-darkest: hsl(var(--color),calc(var(--l) - 10%)); 12 | --color-text: #5b3e81; 13 | --color-text-rgb: 47, 6, 100; 14 | --color-primary-lighter: rgba(var(--color-text-rgb), 50%); 15 | --color-slider-thingy: 38, 166, 154; 16 | 17 | --primary-color: hsla(323, 18%, 49%, 0.924); 18 | --dark-primary-color: #0288d1; 19 | --light-primary-color: #b3e5fC; 20 | --c-pri-rgb: 3, 169, 244; 21 | --c-pri: rgba(var(--c-pri-rgb),100%); 22 | --c-pri-l: rgba(var(--c-pri-rgb), 50%); 23 | --c-pri-d: hsl(var(--c-pri-rgb),calc(var(--l) - 5%); 24 | --color-primary-lighter2: rgba(var(--c-pri), 50%)); 25 | } 26 | @media (prefers-color-scheme: dark) { 27 | :root { 28 | --c-bg: #1c1c1c; 29 | --c-text: #fff; 30 | } 31 | } 32 | 33 | html[color-scheme="dark"] img { 34 | filter: invert(100%); 35 | } 36 | 37 | /* For browsers that don’t support `color-scheme` and therefore 38 | don't handle system dark mode for you automatically 39 | (Firefox), handle it for them. */ 40 | @supports not (color-scheme: light dark) { 41 | html { 42 | background: var(--c-bg); 43 | color: var(--c-text); 44 | } 45 | } 46 | 47 | /* For browsers that support automatic dark/light mode 48 | As well as system colors, set those */ 49 | @supports (color-scheme: light dark) 50 | and (background-color: Canvas) 51 | and (color: CanvasText) { 52 | :root { 53 | --c-bg: Canvas; 54 | --c-text: ButtonText; 55 | } 56 | } 57 | 58 | /* For Safari on iOS. Hacky, but it works. */ 59 | @supports (background-color: -apple-system-control-background) 60 | and (color: text) { 61 | :root { 62 | --c-bg: -apple-system-control-background; 63 | --c-text: text; 64 | } 65 | } 66 | 67 | html { 68 | color-scheme: light dark; 69 | font-family: ui-monospace, system-ui, "Helvetica", "Arial Narrow", "Roboto", "Oxygen", "Ubuntu", sans-serif; 70 | } 71 | 72 | html button, html .btn { 73 | cursor: pointer; 74 | border-radius: 1rem; 75 | background-color: inherit; 76 | background-image: linear-gradient(0deg, rgba(127, 127, 127, 0.5) 0%, rgba(127, 127, 127, 0.5) 100%); 77 | color: inherit; 78 | border: 1px solid rgba(127, 127, 127, 0.5); 79 | height: 1.2rem; 80 | } 81 | 82 | html * { 83 | transition-property: color; 84 | transition-duration: 450ms !important; 85 | transition-timing-function: ease !important; 86 | transition-delay: 0s !important; 87 | } 88 | -------------------------------------------------------------------------------- /packages/v3/src/main.ts: -------------------------------------------------------------------------------- 1 | import "./esp-app" 2 | -------------------------------------------------------------------------------- /packages/v3/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import gzipPlugin from "rollup-plugin-gzip"; 3 | import minifyHTML from "rollup-plugin-minify-html-template-literals"; 4 | import { brotliCompressSync } from "zlib"; 5 | import { nodeResolve } from "@rollup/plugin-node-resolve"; 6 | import loadVersion from "vite-plugin-package-version"; 7 | import { viteSingleFile } from "vite-plugin-singlefile"; 8 | import { minifyHtml as ViteMinifyHtml } from "vite-plugin-html"; 9 | import stripBanner from "rollup-plugin-strip-banner"; 10 | import replace from "@rollup/plugin-replace"; 11 | 12 | const proxy_target = process.env.PROXY_TARGET || "http://nodemcu.local"; 13 | 14 | export default defineConfig({ 15 | clearScreen: false, 16 | plugins: [ 17 | { 18 | ...nodeResolve({ exportConditions: ["development"] }), 19 | enforce: "pre", 20 | apply: "start", 21 | }, 22 | stripBanner(), 23 | loadVersion(), 24 | { ...minifyHTML(), enforce: "pre", apply: "build" }, 25 | // 26 | { 27 | ...ViteMinifyHtml({ removeComments: true }), 28 | enforce: "post", 29 | apply: "build", 30 | }, 31 | replace({ 32 | "@license": "license", 33 | "Value passed to 'css' function must be a 'css' function result:": 34 | "use css function", 35 | "Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.": 36 | "Use unsafeCSS", 37 | delimiters: ["", ""], 38 | preventAssignment: true, 39 | }), 40 | viteSingleFile(), 41 | { 42 | ...gzipPlugin({ 43 | filter: /\.(js|css|html|svg)$/, 44 | additionalFiles: [], 45 | customCompression: (content) => 46 | brotliCompressSync(Buffer.from(content)), 47 | fileName: ".br", 48 | }), 49 | enforce: "post", 50 | apply: "build", 51 | }, 52 | { 53 | ...gzipPlugin({ filter: /\.(js|css|html|svg)$/ }), 54 | enforce: "post", 55 | apply: "build", 56 | }, 57 | ], 58 | build: { 59 | brotliSize: false, 60 | // cssCodeSplit: true, 61 | outDir: "../../_static/v3", 62 | polyfillModulePreload: false, 63 | rollupOptions: { 64 | output: { 65 | manualChunks: (chunk) => { 66 | return "vendor"; 67 | }, // create one js bundle, 68 | chunkFileNames: "[name].js", 69 | assetFileNames: "www[extname]", 70 | entryFileNames: "www.js", 71 | }, 72 | }, 73 | }, 74 | server: { 75 | open: "/", // auto open browser in dev mode 76 | host: true, // dev on local and network 77 | port: 5001, 78 | strictPort: true, 79 | proxy: { 80 | "/light": proxy_target, 81 | "/select": proxy_target, 82 | "/cover": proxy_target, 83 | "/switch": proxy_target, 84 | "/button": proxy_target, 85 | "/fan": proxy_target, 86 | "/lock": proxy_target, 87 | "/number": proxy_target, 88 | "/climate": proxy_target, 89 | "/events": proxy_target, 90 | "/text": proxy_target, 91 | "/date": proxy_target, 92 | "/time": proxy_target, 93 | "/valve": proxy_target, 94 | }, 95 | }, 96 | }); 97 | -------------------------------------------------------------------------------- /scripts/make_header.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cat <./$1/$2 3 | #pragma once 4 | // Generated from https://github.com/esphome/esphome-webserver 5 | 6 | EOT 7 | 8 | if [ -n "$4" ]; then 9 | echo "#ifdef USE_WEBSERVER_LOCAL" >>./$1/$2 10 | echo "#if USE_WEBSERVER_VERSION == $4" >>./$1/$2 11 | echo "" >>./$1/$2 12 | fi 13 | 14 | cat <>./$1/$2 15 | #include "esphome/core/hal.h" 16 | 17 | namespace esphome { 18 | namespace $3 { 19 | 20 | EOT 21 | echo "const uint8_t INDEX_GZ[] PROGMEM = {" >>./$1/$2 22 | xxd -cols 19 -i $1/index.html.gz | sed -e '2,$!d' -e 's/^/ /' -e '$d' | sed -e '$d' | sed -e '$s/$/};/' >>./$1/$2 23 | cat <>./$1/$2 24 | 25 | } // namespace $3 26 | } // namespace esphome 27 | EOT 28 | if [ -n "$4" ]; then 29 | echo "" >>./$1/$2 30 | echo "#endif" >>./$1/$2 31 | echo "#endif" >>./$1/$2 32 | fi 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es2020", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 8 | "module": "es2020", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | // "outDir": "./", /* Redirect output structure to the directory. */ 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true, /* Enable all strict type-checking options. */ 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 43 | // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ 44 | 45 | /* Module Resolution Options */ 46 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 47 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 48 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 49 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 50 | // "typeRoots": [], /* List of folders to include type definitions from. */ 51 | // "types": [], /* Type declaration files to be included in compilation. */ 52 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 53 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 54 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 55 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 56 | 57 | /* Source Map Options */ 58 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 61 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 62 | 63 | /* Experimental Options */ 64 | "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 65 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 66 | 67 | /* Advanced Options */ 68 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 69 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 70 | } 71 | } 72 | --------------------------------------------------------------------------------