├── .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 | 
18 |
19 | Light scheme on mobile:
20 | =======================
21 | 
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 |
28 |
29 |
30 |
31 |
32 | OTA Update
33 |
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([``])
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 ``
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*:first-child{margin-top:0!important}.markdown-body>*:last-child{margin-bottom:0!important}.markdown-body a:not([href]){color:inherit;text-decoration:none}.markdown-body p,.markdown-body blockquote,.markdown-body ul,.markdown-body ol,.markdown-body dl,.markdown-body table,.markdown-body pre{margin-top:0;margin-bottom:16px}.markdown-body hr{height:0.25em;padding:0;margin:24px 0;background-color:#e1e4e8;border:0}.markdown-body blockquote{padding:0 1em;color:#6a737d;border-left:0.25em solid #dfe2e5}.markdown-body blockquote>:first-child{margin-top:0}.markdown-body blockquote>:last-child{margin-bottom:0}.markdown-body h1,.markdown-body h2,.markdown-body h3,.markdown-body h4,.markdown-body h5,.markdown-body h6{margin-top:24px;margin-bottom:16px;font-weight:600;line-height:1.25}.markdown-body h1{padding-bottom:0.3em;font-size:2em;border-bottom:1px solid #eaecef}.markdown-body h2{padding-bottom:0.3em;font-size:1.5em;border-bottom:1px solid #eaecef}.markdown-body h3{font-size:1.25em}.markdown-body h4{font-size:1em}.markdown-body h5{font-size:0.875em}.markdown-body h6{font-size:0.85em;color:#6a737d}.markdown-body ul,.markdown-body ol{padding-left:2em}.markdown-body ul ul,.markdown-body ul ol,.markdown-body ol ol,.markdown-body ol ul{margin-top:0;margin-bottom:0}.markdown-body li{word-wrap:break-all}.markdown-body li>p{margin-top:16px}.markdown-body li+li{margin-top:0.25em}.markdown-body dl{padding:0}.markdown-body dl dt{padding:0;margin-top:16px;font-size:1em;font-style:italic;font-weight:600}.markdown-body dl dd{padding:0 16px;margin-bottom:16px}.markdown-body table{display:block;width:100%;overflow:auto}.markdown-body table th{font-weight:600}.markdown-body table th,.markdown-body table td{padding:6px 13px;border:1px solid #dfe2e5}.markdown-body table tr{background-color:#fff;border-top:1px solid #c6cbd1}.markdown-body table tr:nth-child(2n){background-color:#f6f8fa}.markdown-body img{max-width:100%;box-sizing:content-box;background-color:#fff}.markdown-body img[align=right]{padding-left:20px}.markdown-body img[align=left]{padding-right:20px}.markdown-body pre{padding:16px;overflow:auto;font-size:85%;line-height:1.45;background-color:#f6f8fa;border-radius:3px;word-wrap:normal}.markdown-body:checked+.radio-label{position:relative;z-index:1;border-color:#0366d6}.markdown-body hr{border-bottom-color:#eee}#log .v{color:#888}#log .d{color:#0DD}#log .c{color:magenta}#log .i{color:limegreen}#log .w{color:yellow}#log .e{color:red;font-weight:bold}#log{background-color:#1c1c1c}
--------------------------------------------------------------------------------
/packages/v1/src/webserver-v1.min.js:
--------------------------------------------------------------------------------
1 | const source=new EventSource("/events");source.addEventListener("log",(function(o){const t=document.getElementById("log");let n=[["[1;31m","e"],["[0;33m","w"],["[0;32m","i"],["[0;35m","c"],["[0;36m","d"],["[0;37m","v"]],e="",s="";for(const t of n)o.data.startsWith(t[0])&&(e=t[1],s=t[0]);if(""==e)return void(t.innerHTML+=o.data+"\n");const c=o.data.substr(7,o.data.length-11).split("\n");let i="";const r=c[0].match(/^(.*?\]:)/);r&&(i=r[1]),c.forEach(((o,n)=>{o&&(t.innerHTML+=0===n?''+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
2 |
3 |
4 |
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 |
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 | `;
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 |
126 |
127 |
128 | ${this.config.title}
129 | ❤
130 |
131 | ${this.renderComment()}
132 |
133 |
134 |
135 |
136 | (this.scheme = e.detail.state)}"
141 | labelOn="🌒"
142 | labelOff="☀️"
143 | stateOn="dark"
144 | stateOff="light"
145 | optimistic
146 | >
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 | Name |
120 | State |
121 | ${this.has_controls ? html`Actions | ` : html``}
122 |
123 |
124 |
125 | ${this.entities.map(
126 | (component) => html`
127 |
128 | ${component.name} |
129 | ${component.state} |
130 | ${this.has_controls
131 | ? html`
132 | ${component.has_action ? this.control(component) : html``}
133 | | `
134 | : html``}
135 |
136 | `
137 | )}
138 |
139 |
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` {
229 | let act = "turn_" + e.detail.state;
230 | this.actioner?.restAction(entity, act.toLowerCase());
231 | }}"
232 | >`;
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 | {
282 | let val = e.target?.value;
283 | this.actioner?.restAction(entity, `${action}?${opt}=${val}`);
284 | }}"
285 | />
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 | {
304 | const val = (e.target)?.value;
305 | this.actioner?.restAction(
306 | entity,
307 | `${action}?${opt}=${val.replace('T', ' ')}`
308 | );
309 | }}"
310 | />
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 | {
333 | let val = e.target?.value;
334 | this.actioner?.restAction(entity, `${action}?${opt}=${encodeURIComponent(val)}`);
335 | }}"
336 | />
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 | "[1;31m": "e",
29 | "[0;33m": "w",
30 | "[0;32m": "i",
31 | "[0;35m": "c",
32 | "[0;36m": "d",
33 | "[0;37m": "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 | Time |
80 | level |
81 | Tag |
82 | Message |
83 |
84 |
85 |
86 | ${this.logs.map(
87 | (log: recordConfig) =>
88 | html`
89 |
90 | ${log.when} |
91 | ${log.level} |
92 | ${log.tag} |
93 | ${log.detail} |
94 |
95 |
96 | `
97 | )}
98 |
99 |
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 |
--------------------------------------------------------------------------------
/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`
156 | `;
165 | }
166 | }
167 |
168 | renderLog() {
169 | return this.config.log
170 | ? html``
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 |
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 |
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 | {
401 | const val = (e.target)?.value;
402 | this.actioner?.restAction(
403 | entity,
404 | `${action}?${opt}=${val.replace('T', ' ')}`
405 | );
406 | }}"
407 | />
408 | `;
409 | }
410 |
411 | private _switch(entity: entityConfig) {
412 | return html` {
416 | let act = "turn_" + e.detail.state;
417 | this.actioner?.restAction(entity, act.toLowerCase());
418 | }}"
419 | >`;
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 | {
470 | const val = (e.target)?.value;
471 | this.actioner?.restAction(entity, `${action}?${opt}=${val}`);
472 | }}"
473 | />
474 |
475 |
`;
476 | } else {
477 | return html`
478 | {
485 | const val = (e.target)?.value;
486 | this.actioner?.restAction(entity, `${action}?${opt}=${e.detail.state}`);
487 | }}"
488 | >`;
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 | {
512 | const val = (e.target)?.value;
513 | this.actioner?.restAction(
514 | entity,
515 | `${action}?${opt}=${encodeURIComponent(val)}`
516 | );
517 | }}"
518 | />
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 | {
540 | const val = (e.target)?.value;
541 | this.actioner?.restAction(entity, `${action}?${rgb_to_str(val)}`);
542 | }}"
543 | />
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 | "[1;31m": "e",
31 | "[0;33m": "w",
32 | "[0;32m": "i",
33 | "[0;35m": "c",
34 | "[0;36m": "d",
35 | "[0;37m": "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 |
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 |
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 |
--------------------------------------------------------------------------------