├── src ├── iif.js ├── ui │ ├── SerialConnectButton.module.css │ ├── components │ │ ├── entities │ │ │ ├── ButtonEntity.module.css │ │ │ ├── inputs │ │ │ │ ├── ColorModeInput.module.css │ │ │ │ ├── NumberInput.module.css │ │ │ │ ├── DropDownInput.jsx │ │ │ │ ├── BrightnessInput.jsx │ │ │ │ ├── RangeInput.jsx │ │ │ │ ├── ColorInput.jsx │ │ │ │ ├── ToggleInput.jsx │ │ │ │ ├── NumberInput.jsx │ │ │ │ ├── ColorModeInput.jsx │ │ │ │ ├── ColorTemperatureInput.jsx │ │ │ │ ├── RGBInput.jsx │ │ │ │ ├── ToggleInput.module.css │ │ │ │ └── ResponsiveInput.jsx │ │ │ ├── getEntityLabel.js │ │ │ ├── LockEntity.module.css │ │ │ ├── SwitchEntity.module.css │ │ │ ├── SelectEntity.module.css │ │ │ ├── LightColorComponent.module.css │ │ │ ├── SensorEntity.jsx │ │ │ ├── TextSensorEntity.jsx │ │ │ ├── BinarySensorEntity.jsx │ │ │ ├── StateEntity.module.css │ │ │ ├── TextEntity.module.css │ │ │ ├── CoverEntity.module.css │ │ │ ├── StateEntity.jsx │ │ │ ├── SelectEntity.jsx │ │ │ ├── ButtonEntity.jsx │ │ │ ├── FanEntity.module.css │ │ │ ├── NumberEntity.jsx │ │ │ ├── SwitchEntity.jsx │ │ │ ├── useEntityState.js │ │ │ ├── LockEntity.jsx │ │ │ ├── LightComponent.module.css │ │ │ ├── TextEntity.jsx │ │ │ ├── LightColorComponent.jsx │ │ │ ├── FanEntity.jsx │ │ │ ├── LightComponent.jsx │ │ │ ├── CoverEntity.jsx │ │ │ └── ClimateEntity.jsx │ │ ├── SerialConnectionList.module.css │ │ ├── ImprovWifi.module.css │ │ ├── EntitySection.module.css │ │ ├── SerialConnectionCard.module.css │ │ ├── EntitySection.jsx │ │ ├── EntityCard.jsx │ │ ├── EntityCard.module.css │ │ ├── DrawerCard.module.css │ │ ├── ControllerList.module.css │ │ ├── WifiSelectionComponent.module.css │ │ ├── SerialConnectionList.jsx │ │ ├── ImprovWifi.jsx │ │ ├── DrawerCard.jsx │ │ ├── WifiSelectionComponent.jsx │ │ ├── useImprovSerial.js │ │ ├── SerialConnectionCard.jsx │ │ ├── FirmwareFlasher.jsx │ │ └── ControllerList.jsx │ ├── utility.module.css │ ├── Spinner.jsx │ ├── RadialProgress.jsx │ ├── Toast.jsx │ ├── css.js │ ├── RadialProgress.module.css │ ├── SerialConnectButton.jsx │ ├── Toast.module.css │ ├── Spinner.module.css │ ├── Drawer.jsx │ ├── Drawer.module.css │ ├── Header.module.css │ └── Header.jsx ├── sleep.js ├── createAddHostURL.js ├── config.js ├── main.module.css ├── isPrivateAddressSpace.js ├── main.css ├── BetterSerialPorts.js ├── ControllerRegistry.js ├── FetchEventSource.js ├── sw.js └── main.jsx ├── .gitignore ├── static ├── icons │ ├── 192.png │ └── 256.png └── site.webmanifest ├── images └── esphome-web-app.png ├── index.html ├── package.json ├── LICENSE.md ├── scripts └── release.sh ├── vite.config.js └── README.md /src/iif.js: -------------------------------------------------------------------------------- 1 | export default (expr, vTrue, vFalse) => expr ? vTrue : vFalse; 2 | -------------------------------------------------------------------------------- /src/ui/SerialConnectButton.module.css: -------------------------------------------------------------------------------- 1 | .active { 2 | color: steelblue; 3 | } 4 | -------------------------------------------------------------------------------- /src/ui/components/entities/ButtonEntity.module.css: -------------------------------------------------------------------------------- 1 | .button { 2 | flex-basis: 1; 3 | } 4 | -------------------------------------------------------------------------------- /src/ui/components/SerialConnectionList.module.css: -------------------------------------------------------------------------------- 1 | .list { 2 | margin-bottom: 1rem; 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.swo 3 | public/ 4 | node_modules/ 5 | .script-env 6 | *.DS_Store 7 | .npmrc 8 | -------------------------------------------------------------------------------- /src/ui/components/ImprovWifi.module.css: -------------------------------------------------------------------------------- 1 | .link { 2 | text-decoration: inherit; 3 | } 4 | 5 | 6 | -------------------------------------------------------------------------------- /static/icons/192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DanielBaulig/esphome-web-app/HEAD/static/icons/192.png -------------------------------------------------------------------------------- /static/icons/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DanielBaulig/esphome-web-app/HEAD/static/icons/256.png -------------------------------------------------------------------------------- /images/esphome-web-app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DanielBaulig/esphome-web-app/HEAD/images/esphome-web-app.png -------------------------------------------------------------------------------- /src/ui/components/EntitySection.module.css: -------------------------------------------------------------------------------- 1 | .section { 2 | background-color: var(--section-background); 3 | } 4 | -------------------------------------------------------------------------------- /src/ui/components/entities/inputs/ColorModeInput.module.css: -------------------------------------------------------------------------------- 1 | .colorModeInput { 2 | padding-left: 0.5rem; 3 | } 4 | -------------------------------------------------------------------------------- /src/sleep.js: -------------------------------------------------------------------------------- 1 | export default async function sleep(ms) { 2 | return new Promise(resolve => setTimeout(resolve, ms)); 3 | } 4 | -------------------------------------------------------------------------------- /src/createAddHostURL.js: -------------------------------------------------------------------------------- 1 | export default function createAddHostURL(host) { 2 | return `${location.href}#?addhost=${host}`; 3 | } 4 | -------------------------------------------------------------------------------- /src/ui/components/entities/getEntityLabel.js: -------------------------------------------------------------------------------- 1 | export default function getEntityLabel(state) { 2 | return state.name || state.slug; 3 | } 4 | -------------------------------------------------------------------------------- /src/ui/components/entities/LockEntity.module.css: -------------------------------------------------------------------------------- 1 | .state { 2 | display: flex; 3 | justify-content: center; 4 | margin: 0; 5 | flex-basis: 100%; 6 | } 7 | -------------------------------------------------------------------------------- /src/ui/components/SerialConnectionCard.module.css: -------------------------------------------------------------------------------- 1 | .closeButton { 2 | background-color: transparent; 3 | border: none; 4 | display: flex; 5 | cursor: pointer; 6 | } 7 | -------------------------------------------------------------------------------- /src/ui/components/entities/SwitchEntity.module.css: -------------------------------------------------------------------------------- 1 | .switchEntity > div { 2 | display: flex; 3 | justify-content: center; 4 | flex-grow: 1; 5 | margin: 0.5em 0; 6 | } 7 | -------------------------------------------------------------------------------- /src/ui/components/entities/SelectEntity.module.css: -------------------------------------------------------------------------------- 1 | .select { 2 | composes: entityCard from '../EntityCard.module.css'; 3 | } 4 | 5 | .select select { 6 | flex-grow: 1; 7 | } 8 | -------------------------------------------------------------------------------- /src/ui/utility.module.css: -------------------------------------------------------------------------------- 1 | .flex { 2 | display: flex; 3 | flex-grow: 1; 4 | justify-content: center; 5 | } 6 | 7 | .flexFill { 8 | flex-basis: 100%; 9 | } 10 | 11 | -------------------------------------------------------------------------------- /src/ui/components/entities/LightColorComponent.module.css: -------------------------------------------------------------------------------- 1 | .colorComponent { 2 | display: flex; 3 | flex-wrap: wrap; 4 | } 5 | 6 | .colorComponent > * { 7 | flex-basis: 100%; 8 | } 9 | -------------------------------------------------------------------------------- /src/ui/components/entities/SensorEntity.jsx: -------------------------------------------------------------------------------- 1 | import StateEntity from './StateEntity'; 2 | 3 | export default function SensorEntity({entity}) { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/ui/components/entities/TextSensorEntity.jsx: -------------------------------------------------------------------------------- 1 | import StateEntity from './StateEntity'; 2 | 3 | export default function TextSensorEntity({entity}) { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/ui/components/entities/BinarySensorEntity.jsx: -------------------------------------------------------------------------------- 1 | import StateEntity from './StateEntity'; 2 | 3 | export default function BinarySensorEntity({entity}) { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/ui/components/entities/StateEntity.module.css: -------------------------------------------------------------------------------- 1 | .stateEntity { 2 | flex-basis: 1; 3 | } 4 | 5 | .stateEntity h3 { 6 | display: flex; 7 | justify-content: center; 8 | flex-grow: 1; 9 | } 10 | -------------------------------------------------------------------------------- /src/ui/components/entities/inputs/NumberInput.module.css: -------------------------------------------------------------------------------- 1 | .numberInput { 2 | display: flex; 3 | justify-content: center; 4 | flex-grow: 1; 5 | font-size: 1rem; 6 | font-weight: 600; 7 | line-height: 2em; 8 | width: 7ch; 9 | font-family: system-ui; 10 | } 11 | -------------------------------------------------------------------------------- /src/ui/components/entities/inputs/DropDownInput.jsx: -------------------------------------------------------------------------------- 1 | export default function DropDownInput({options, value, ...props}) { 2 | return ( 3 | 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /src/ui/Spinner.jsx: -------------------------------------------------------------------------------- 1 | import { spinner } from './Spinner.module.css'; 2 | 3 | export default function Spinner({className}) { 4 | return
5 |
6 |
7 |
8 |
9 |
; 10 | } 11 | -------------------------------------------------------------------------------- /src/ui/components/entities/inputs/BrightnessInput.jsx: -------------------------------------------------------------------------------- 1 | import RangeInput from './RangeInput'; 2 | 3 | export default function BrightnessInput({value, onChange}) { 4 | return ; 10 | } 11 | -------------------------------------------------------------------------------- /src/ui/components/entities/TextEntity.module.css: -------------------------------------------------------------------------------- 1 | .input { 2 | display: flex; 3 | justify-content: center; 4 | flex-grow: 1; 5 | font-size: 1rem; 6 | font-weight: 600; 7 | line-height: 2em; 8 | width: 7ch; 9 | font-family: system-ui; 10 | } 11 | 12 | .input:invalid{ 13 | background-color: var(--card-background-warning); 14 | } 15 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | const config = (await import.meta.glob('../esphome-web.json', {eager: true}))['../esphome-web.json'] || {}; 2 | 3 | export const title = config.title || 'ESPHome Web App'; 4 | export const filters = config.filters || []; 5 | export const insecureOrigin = config.insecureOrigin; 6 | export const insecureOriginTemplate = config.insecureOriginTemplate; 7 | -------------------------------------------------------------------------------- /src/ui/components/EntitySection.jsx: -------------------------------------------------------------------------------- 1 | import EntityCard from './EntityCard'; 2 | 3 | import { section as sectionClass } from './EntitySection.module.css' 4 | 5 | export default function EntitySection({className, children, ...props}) { 6 | return 7 | {children} 8 | ; 9 | } 10 | -------------------------------------------------------------------------------- /src/ui/components/EntityCard.jsx: -------------------------------------------------------------------------------- 1 | import css from '../css'; 2 | 3 | import { entityCard } from './EntityCard.module.css'; 4 | 5 | export default function EntityCard({title, onClick, className, children}) { 6 | return
7 | {title} 8 | {children} 9 |
; 10 | } 11 | -------------------------------------------------------------------------------- /src/ui/components/entities/CoverEntity.module.css: -------------------------------------------------------------------------------- 1 | .state { 2 | } 3 | 4 | .state h3 { 5 | display: flex; 6 | justify-content: center; 7 | margin: 0; 8 | flex-basis: 100%; 9 | } 10 | 11 | .position { 12 | display: flex; 13 | justify-content: center; 14 | flex-basis: 100%; 15 | } 16 | 17 | .tilt { 18 | display: flex; 19 | justify-content: center; 20 | flex-basis: 100%; 21 | } 22 | -------------------------------------------------------------------------------- /src/ui/components/entities/inputs/RangeInput.jsx: -------------------------------------------------------------------------------- 1 | import {useId} from 'react'; 2 | 3 | import ResponsiveInput from './ResponsiveInput'; 4 | 5 | export default function RangeInput({label, ...props}) { 6 | const id = useId(); 7 | 8 | return <> 9 | 10 | 15 | ; 16 | } 17 | -------------------------------------------------------------------------------- /static/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ESPHome Link", 3 | "icons": [{ 4 | "src": "icons/256.png", 5 | "type": "image/png", 6 | "sizes": "256x256" 7 | }, { 8 | "src": "icons/192.png", 9 | "type": "image/png", 10 | "sizes": "192x192" 11 | }], 12 | "start_url": ".", 13 | "display": "standalone", 14 | "description": "A standalone web app to control ESPHome based IOT devices" 15 | } 16 | -------------------------------------------------------------------------------- /src/ui/RadialProgress.jsx: -------------------------------------------------------------------------------- 1 | import { radialProgess } from './RadialProgress.module.css'; 2 | 3 | export default function RadialProgess({progress}) { 4 | progress = Math.max(0, Math.min(1, progress)); 5 | return
8 |
9 | {Math.round(progress * 100)}% 10 |
; 11 | } 12 | -------------------------------------------------------------------------------- /src/ui/Toast.jsx: -------------------------------------------------------------------------------- 1 | import Drawer from './Drawer'; 2 | 3 | import { useRef } from 'react'; 4 | 5 | import { toast, content, warning } from './Toast.module.css'; 6 | 7 | const styles = { 8 | "warning": warning, 9 | }; 10 | 11 | export default function Toast({visible, style, children}) { 12 | return 16 |
{children}
17 |
; 18 | } 19 | -------------------------------------------------------------------------------- /src/ui/components/EntityCard.module.css: -------------------------------------------------------------------------------- 1 | .entityCard { 2 | border-radius: 0 0 12px 12px; 3 | margin: 0.1em 0.3em; 4 | flex-grow: 1; 5 | display: flex; 6 | justify-content: center; 7 | align-items: center; 8 | padding-top: 0.5em; 9 | padding-bottom: 0.5em; 10 | gap: 1rem; 11 | flex-wrap: wrap; 12 | } 13 | 14 | .entityCard button { 15 | background-color: var(--primary-color); 16 | border-radius: 8px; 17 | min-height: 3rem; 18 | flex-grow: 1; 19 | } 20 | -------------------------------------------------------------------------------- /src/ui/components/entities/inputs/ColorInput.jsx: -------------------------------------------------------------------------------- 1 | import {useId, useState, useRef, useEffect} from 'react'; 2 | import ResponsiveInput from './ResponsiveInput'; 3 | 4 | export default function ColorInput({color, label, onChange}) { 5 | const id = useId(); 6 | 7 | return <> 8 | 9 | 15 | ; 16 | } 17 | -------------------------------------------------------------------------------- /src/ui/components/entities/inputs/ToggleInput.jsx: -------------------------------------------------------------------------------- 1 | import { useId } from 'react'; 2 | import { toggle } from './ToggleInput.module.css'; 3 | 4 | export default function ToggleInput({checked, onChange}) { 5 | const id = useId(); 6 | return 7 | 14 | 15 | ; 16 | } 17 | -------------------------------------------------------------------------------- /src/ui/components/entities/StateEntity.jsx: -------------------------------------------------------------------------------- 1 | import useEntityState from './useEntityState'; 2 | import getEntityLabel from './getEntityLabel'; 3 | import EntityCard from '../EntityCard'; 4 | 5 | 6 | import { stateEntity } from './StateEntity.module.css'; 7 | 8 | export default function StateEntity({entity}) { 9 | const state = useEntityState(entity); 10 | 11 | return 12 |

{state.state}

13 |
; 14 | } 15 | 16 | -------------------------------------------------------------------------------- /src/main.module.css: -------------------------------------------------------------------------------- 1 | .footer { 2 | display: flex; 3 | justify-content: center; 4 | font-size: smaller; 5 | flex-wrap: wrap; 6 | } 7 | 8 | .footer a { 9 | display: flex; 10 | justify-content: center; 11 | flex-basis: 100%; 12 | color: black; 13 | } 14 | 15 | .dropTarget { 16 | /* prevent margin collapse */ 17 | overflow: auto; 18 | } 19 | 20 | .dropIndicator { 21 | height: 2px; 22 | background-color: black; 23 | margin: 0.5rem 8rem; 24 | } 25 | 26 | .dropSpacer { 27 | height: 2rem; 28 | } 29 | -------------------------------------------------------------------------------- /src/ui/css.js: -------------------------------------------------------------------------------- 1 | function join(a, b) { 2 | return `${a}${a.length > 0 ? ' ':''}${b}`; 3 | } 4 | 5 | export default function css(...args) { 6 | return args.reduce((a, v) => { 7 | if (v === undefined) { 8 | return a; 9 | } 10 | if (typeof v === 'object') { 11 | return Object.entries(v).reduce((a, [k, v]) => { 12 | if (!v) { 13 | return a; 14 | } 15 | 16 | return join(a, k); 17 | }, a); 18 | } 19 | 20 | return join(a, v); 21 | }, ''); 22 | } 23 | -------------------------------------------------------------------------------- /src/ui/components/entities/SelectEntity.jsx: -------------------------------------------------------------------------------- 1 | import useEntityState from './useEntityState'; 2 | 3 | import { select } from './SelectEntity.module.css'; 4 | 5 | export default function SelectEntity({entity}) { 6 | const state = useEntityState(entity); 7 | 8 | return
9 | {state.name || entity.slug} 10 | 13 |
; 14 | } 15 | -------------------------------------------------------------------------------- /src/ui/components/entities/ButtonEntity.jsx: -------------------------------------------------------------------------------- 1 | import useEntityState from './useEntityState'; 2 | import getEntityLabel from './getEntityLabel'; 3 | 4 | import EntityCard from '../EntityCard'; 5 | 6 | import { button } from './ButtonEntity.module.css'; 7 | 8 | export default function ButtonEntity({entity}) { 9 | const state = useEntityState(entity); 10 | const label = getEntityLabel(state); 11 | 12 | return entity.press()} 16 | > 17 | 18 | ; 19 | } 20 | -------------------------------------------------------------------------------- /src/ui/components/entities/inputs/NumberInput.jsx: -------------------------------------------------------------------------------- 1 | import ResponsiveInput from './ResponsiveInput'; 2 | 3 | import { numberInput } from './NumberInput.module.css'; 4 | import css from '../../../css'; 5 | 6 | export default function NumberInput({value, onChange, min, max, step, className, ...props}) { 7 | return ( 8 | 18 | ); 19 | } 20 | 21 | -------------------------------------------------------------------------------- /src/ui/RadialProgress.module.css: -------------------------------------------------------------------------------- 1 | .radialProgess { 2 | display: flex; 3 | position: relative; 4 | justify-content: center; 5 | align-items: center; 6 | width: 80px; 7 | height: 80px; 8 | } 9 | 10 | .radialProgess div { 11 | content: ''; 12 | position: absolute; 13 | inset: 8px; 14 | border-radius: 50%; 15 | padding: 8px; 16 | background-image: conic-gradient(#000 360deg, transparent 0deg); 17 | -webkit-mask: 18 | linear-gradient(#fff 0 0) content-box, 19 | linear-gradient(#fff 0 0); 20 | -webkit-mask-composite: xor; 21 | mask-composite: exclude; 22 | } 23 | -------------------------------------------------------------------------------- /src/ui/components/entities/inputs/ColorModeInput.jsx: -------------------------------------------------------------------------------- 1 | import { colorModeInput } from './ColorModeInput.module.css'; 2 | export default function ColorModeInput({colorMode, onChange, colorModes={rgb: 'RGB', color_temp: 'Temperature'}}) { 3 | return <> 4 | {Object.keys(colorModes).map((mode) => )} 14 | ; 15 | } 16 | -------------------------------------------------------------------------------- /src/ui/components/entities/inputs/ColorTemperatureInput.jsx: -------------------------------------------------------------------------------- 1 | import { useId } from 'react'; 2 | 3 | import RangeInput from './RangeInput'; 4 | 5 | function miredsToKelvin(mireds) { 6 | return 1000000 / mireds; 7 | } 8 | 9 | function kelvinToMireds(kelvin) { 10 | return 1000000 / kelvin; 11 | } 12 | 13 | export default function ColorTemperatureInput({value, onChange}) { 14 | const min = 2400; 15 | const max = 6500; 16 | 17 | return <> 18 | onChange(kelvinToMireds(value))} 23 | /> 24 | ; 25 | } 26 | -------------------------------------------------------------------------------- /src/ui/components/entities/FanEntity.module.css: -------------------------------------------------------------------------------- 1 | .fan li { 2 | display: flex; 3 | align-content: center; 4 | justify-content: center; 5 | } 6 | 7 | .fan li:first-child { 8 | margin: 0.5em 0; 9 | } 10 | 11 | .fan { 12 | align-items: flex-start; 13 | } 14 | 15 | .speedFan { 16 | flex-basis: 100%; 17 | } 18 | 19 | .fan ul { 20 | list-style-type: none; 21 | padding: 0; 22 | flex-grow: 1; 23 | } 24 | 25 | .speed { 26 | background-color: var(--section-background); 27 | display: flex; 28 | flex-wrap: wrap; 29 | justify-content: center; 30 | align-items: center; 31 | } 32 | 33 | .speed > * { 34 | flex-basis: 100%; 35 | } 36 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ESPHome Web App 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/ui/components/entities/NumberEntity.jsx: -------------------------------------------------------------------------------- 1 | import EntityCard from '../EntityCard'; 2 | import NumberInput from './inputs/NumberInput'; 3 | 4 | import useEntityState from './useEntityState'; 5 | import getEntityLabel from './getEntityLabel'; 6 | 7 | export default function NumberEntity({entity}) { 8 | const state = useEntityState(entity); 9 | 10 | return 11 | entity.set(v)} 15 | min={state.min_value} 16 | max={state.max_value} 17 | step={state.step} 18 | /> 19 | ; 20 | } 21 | -------------------------------------------------------------------------------- /src/ui/components/entities/SwitchEntity.jsx: -------------------------------------------------------------------------------- 1 | import useEntityState from './useEntityState'; 2 | import getEntityLabel from './getEntityLabel.js'; 3 | 4 | 5 | import ToggleInput from './inputs/ToggleInput'; 6 | import EntityCard from '../EntityCard'; 7 | 8 | import { switchEntity } from './SwitchEntity.module.css'; 9 | 10 | export default function SwitchEntity({entity}) { 11 | const state = useEntityState(entity); 12 | 13 | return 14 |
15 | entity.toggle()} /> 16 |
17 |
; 18 | } 19 | -------------------------------------------------------------------------------- /src/ui/SerialConnectButton.jsx: -------------------------------------------------------------------------------- 1 | import betterSerial from '../BetterSerialPorts'; 2 | import { useCallback, useReducer } from 'react'; 3 | import { active } from './SerialConnectButton.module.css'; 4 | 5 | export default function SerialConnectButton({ children, onConnectPort, ...props }) { 6 | const supportsSerial = !!betterSerial; 7 | 8 | if (!supportsSerial) { 9 | return null; 10 | } 11 | 12 | return ; 23 | } 24 | -------------------------------------------------------------------------------- /src/ui/Toast.module.css: -------------------------------------------------------------------------------- 1 | .warning { 2 | background-color: var(--card-background-warning); 3 | } 4 | 5 | .toast { 6 | border: 3px outset black; 7 | border-radius: 15px; 8 | margin-bottom: 0em; 9 | } 10 | 11 | .toast:global(.animation-enter) { 12 | margin-bottom: 0em; 13 | } 14 | 15 | .toast:global(.animation-enter-active) { 16 | transition: grid-template-rows 300ms ease-in; 17 | } 18 | 19 | .toast:global(.animation-enter-done) { 20 | transition: margin-bottom 200ms ease-out; 21 | margin-bottom: 1em; 22 | } 23 | 24 | .toast:global(.animation-exit) { 25 | transition: all 500ms ease-in-out; 26 | } 27 | 28 | .toast:global(.animation-exit-active) { 29 | margin-bottom: 0em; 30 | } 31 | 32 | .content { 33 | padding: 1rem; 34 | } 35 | -------------------------------------------------------------------------------- /src/ui/components/entities/useEntityState.js: -------------------------------------------------------------------------------- 1 | import {useReducer, useEffect} from 'react'; 2 | 3 | export default function useEntityState(entity) { 4 | const [state, dispatch] = useReducer((state, action) => { 5 | switch(action.type) { 6 | case 'update': 7 | return { ...state, ...action.state }; 8 | } 9 | throw new Error(`Invalid action type ${action.type}.`); 10 | }, entity.data); 11 | useEffect(() => { 12 | const listener = (event) => { 13 | dispatch({type: 'update', state: entity.data}); 14 | } 15 | entity.addEventListener('update', listener); 16 | return () => { 17 | entity.removeEventListener('update', listener); 18 | }; 19 | }, [entity]); 20 | 21 | return state; 22 | } 23 | 24 | -------------------------------------------------------------------------------- /src/ui/components/entities/inputs/RGBInput.jsx: -------------------------------------------------------------------------------- 1 | import ColorInput from './ColorInput'; 2 | 3 | function hexToRGB(hex) { 4 | const parts = /^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); 5 | return { 6 | red: parseInt(parts[1], 16), 7 | green: parseInt(parts[2], 16), 8 | blue: parseInt(parts[3], 16), 9 | }; 10 | } 11 | 12 | function rgbToHex(red, green, blue) { 13 | const colors = [red, green, blue].map((c) => { 14 | const hex = c.toString(16); 15 | return `${hex.length == 1 ? '0' : ''}${hex}`; 16 | }); 17 | return `#${colors.join('')}`; 18 | } 19 | 20 | export default function RGBInput({red, green, blue, onChange}) { 21 | const hex = rgbToHex(red, green, blue); 22 | 23 | return onChange(hexToRGB(value))} 26 | />; 27 | } 28 | -------------------------------------------------------------------------------- /src/ui/Spinner.module.css: -------------------------------------------------------------------------------- 1 | .spinner { 2 | display: inline-block; 3 | position: relative; 4 | width: 80px; 5 | height: 80px; 6 | } 7 | .spinner div { 8 | box-sizing: border-box; 9 | display: block; 10 | position: absolute; 11 | width: 64px; 12 | height: 64px; 13 | margin: 8px; 14 | border: 8px solid #000; 15 | border-radius: 50%; 16 | animation: spinner 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; 17 | border-color: #000 transparent transparent transparent; 18 | } 19 | .spinner div:nth-child(1) { 20 | animation-delay: -0.45s; 21 | } 22 | .spinner div:nth-child(2) { 23 | animation-delay: -0.3s; 24 | } 25 | .spinner div:nth-child(3) { 26 | animation-delay: -0.15s; 27 | } 28 | @keyframes spinner { 29 | 0% { 30 | transform: rotate(0deg); 31 | } 32 | 100% { 33 | transform: rotate(360deg); 34 | } 35 | } 36 | 37 | -------------------------------------------------------------------------------- /src/ui/components/DrawerCard.module.css: -------------------------------------------------------------------------------- 1 | .header { 2 | display: flex; 3 | align-items: center; 4 | background-color: var(--primary-color); 5 | padding: 0 0 0 0.5rem; 6 | } 7 | 8 | .header button { 9 | min-height: 0; 10 | border: none; 11 | background-color: transparent; 12 | } 13 | 14 | .title { 15 | padding: 0 0 0 0.25rem; 16 | flex-grow: 1; 17 | text-align: left; 18 | user-select: text; 19 | } 20 | 21 | .title h3 { 22 | margin: 0.5rem 0; 23 | } 24 | 25 | .card { 26 | box-shadow: var(--box-shadow); 27 | border: 3px outset black; 28 | border-radius: 15px; 29 | overflow: hidden; 30 | } 31 | 32 | .drawer { 33 | justify-content: center; 34 | border-top: 1px solid black; 35 | background-color: var(--card-background); 36 | grid-template-columns: 1fr; 37 | } 38 | 39 | .drawer:last-child:after { 40 | content: ''; 41 | flex-grow: 1; 42 | margin-bottom: 0.3em; 43 | } 44 | -------------------------------------------------------------------------------- /src/ui/components/entities/LockEntity.jsx: -------------------------------------------------------------------------------- 1 | import EntityCard from '../EntityCard'; 2 | import EntitySection from '../EntitySection'; 3 | 4 | import useEntityState from './useEntityState'; 5 | import getEntityLabel from './getEntityLabel'; 6 | import iif from '../../../iif'; 7 | 8 | import { useState, useEffect } from 'react'; 9 | import { state as stateClass } from './LockEntity.module.css'; 10 | 11 | export default function LockEntity({entity}) { 12 | const state = useEntityState(entity); 13 | 14 | const buttons = <> 15 | 16 | {iif(state.supports_open, )} 17 | 18 | ; 19 | 20 | return 21 |

{state.state}

22 | {buttons} 23 |
; 24 | } 25 | -------------------------------------------------------------------------------- /src/ui/components/ControllerList.module.css: -------------------------------------------------------------------------------- 1 | .controllerList { 2 | list-style-type: none; 3 | padding: 0; 4 | margin: 0; 5 | } 6 | 7 | .closeButton { 8 | cursor: pointer; 9 | display: flex; 10 | background-color: transparent; 11 | border: none; 12 | } 13 | 14 | .messageIcon { 15 | color: var(--section-background); 16 | margin-top: 1rem; 17 | } 18 | 19 | .dropIndicator { 20 | height: 2px; 21 | background-color: black; 22 | margin: 0.5rem 8rem; 23 | } 24 | 25 | .dragging { 26 | /* using a short transition will allow the browser to grab a 27 | * "screenshot" for the visual before hiding the element */ 28 | transition: 0.01s; 29 | /* remove it from document flow */ 30 | position: absolute; 31 | /* have to set an absolute width because of position: absolute */ 32 | width: 400px; 33 | /* hide it */ 34 | visibility: hidden; 35 | } 36 | 37 | .item { 38 | /* Prevent margin collpase for drop indicator */ 39 | overflow: auto; 40 | } 41 | -------------------------------------------------------------------------------- /src/ui/components/entities/LightComponent.module.css: -------------------------------------------------------------------------------- 1 | .lightComponent { 2 | composes: entityCard from '../EntityCard.module.css'; 3 | } 4 | 5 | .lightComponent ul { 6 | list-style-type: none; 7 | padding: 0; 8 | flex-grow: 1; 9 | } 10 | 11 | .lightComponent ul > li { 12 | margin: 0 0 16px 0; 13 | } 14 | 15 | .lightComponent ul > li:last-child { 16 | margin: 0; 17 | } 18 | 19 | .lightComponent fieldset { 20 | border-radius: 0 0 8px 8px; 21 | background-color: var(--section-background); 22 | } 23 | 24 | .state { 25 | display: flex; 26 | flex-wrap: wrap; 27 | flex-grow: 1; 28 | justify-content: center; 29 | align-items: center; 30 | gap: 1rem; 31 | } 32 | 33 | .state h3 { 34 | display: flex; 35 | justify-content: center; 36 | margin: 0; 37 | flex-basis: 100%; 38 | } 39 | 40 | .brightness { 41 | display: flex; 42 | flex-wrap: wrap; 43 | justify-content: center; 44 | align-items: center; 45 | } 46 | 47 | .brightness > * { 48 | flex-basis: 100%; 49 | } 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "esphome-web-app", 3 | "version": "0.2.2", 4 | "description": "A standalone app for flashing, provisioning and controlling of the ESPhome devices", 5 | "keywords": [ 6 | "esphome", 7 | "web", 8 | "home", 9 | "assistant", 10 | "esp32", 11 | "esp8266", 12 | "home", 13 | "automation" 14 | ], 15 | "dependencies": { 16 | "@mdi/js": "^7.4.47", 17 | "@mdi/react": "^1.6.1", 18 | "@vitejs/plugin-react": "^4.1.0", 19 | "esphome-web": "^0.2.3", 20 | "esptool-js": "^0.4.1", 21 | "improv-wifi-serial-sdk": "^2.5.0", 22 | "react": "^18.2.0", 23 | "react-dom": "^18.2.0", 24 | "react-transition-group": "^4.4.5", 25 | "rollup-plugin-output-manifest": "^2.0.0", 26 | "vite": ">=4.5.3", 27 | "vite-plugin-top-level-await": "^1.4.1" 28 | }, 29 | "scripts": { 30 | "dev": "vite", 31 | "build": "vite build", 32 | "preview": "vite preview", 33 | "release": "scripts/release.sh" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/ui/Drawer.jsx: -------------------------------------------------------------------------------- 1 | import { CSSTransition } from 'react-transition-group'; 2 | import { useRef } from 'react'; 3 | 4 | import {drawer, content, vertical, horizontal} from './Drawer.module.css'; 5 | 6 | const directions = { 7 | vertical: vertical, 8 | horizontal: horizontal, 9 | }; 10 | 11 | export default function Drawer({open, onDoneClosing, onDoneOpening, className, direction = 'vertical', children}) { 12 | const wrapperRef = useRef(null); 13 | return { 18 | wrapperRef.current.addEventListener('transitionend', done, false); 19 | }} 20 | timeout={1200} 21 | appear={true} 22 | onExited={onDoneClosing} 23 | onEntered={onDoneOpening} 24 | unmountOnExit={true} 25 | mountOnEnter={true} 26 | > 27 |
28 |
29 | {children} 30 |
31 |
32 |
; 33 | } 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/ui/components/entities/TextEntity.jsx: -------------------------------------------------------------------------------- 1 | import EntityCard from '../EntityCard'; 2 | import ResponsiveInput from './inputs/ResponsiveInput'; 3 | 4 | import useEntityState from './useEntityState'; 5 | import getEntityLabel from './getEntityLabel'; 6 | 7 | import { useMemo } from 'react'; 8 | 9 | import { input } from './TextEntity.module.css'; 10 | 11 | const MODE_TEXT = 0; 12 | const MODE_PASSWORD = 1; 13 | 14 | export default function TextEntity({entity}) { 15 | const state = useEntityState(entity); 16 | const rx = useMemo(() => RegExp(state.pattern), [state.pattern]); 17 | 18 | return 19 | { 24 | if (!v.match(rx)) { 25 | return; 26 | } 27 | 28 | entity.set(v); 29 | }} 30 | minLength={state.min_length} 31 | maxLength={state.max_length} 32 | pattern={state.pattern ? state.pattern : undefined} 33 | /> 34 | ; 35 | } 36 | -------------------------------------------------------------------------------- /src/ui/components/entities/inputs/ToggleInput.module.css: -------------------------------------------------------------------------------- 1 | .toggle > input { 2 | visibility: hidden; 3 | width: 0; 4 | height: 0; 5 | } 6 | 7 | .toggle > label { 8 | --size: 34px; 9 | --spacing: 4px; 10 | --width: calc(1.75 * var(--size) + 2*var(--spacing)); 11 | --height: calc(var(--size) + 2*var(--spacing)); 12 | cursor: pointer; 13 | display: inline-block; 14 | width: var(--width); 15 | height: var(--height); 16 | border-radius: var(--height); 17 | position: relative; 18 | background-color: var(--primary-background-30); 19 | box-sizing: content-box; 20 | } 21 | 22 | .toggle > input:checked + label { 23 | background-color: var(--primary-color); 24 | opacity: 1; 25 | } 26 | 27 | .toggle > input + label:after { 28 | content: ''; 29 | position: absolute; 30 | border-radius: 100%; 31 | top: var(--spacing); 32 | left: var(--spacing); 33 | width: var(--size); 34 | height: var(--size); 35 | transition: 0.4s; 36 | background-color: var(--card-background); 37 | } 38 | 39 | .toggle > input:checked + label:after { 40 | transform: translateX(calc(var(--width) - var(--size) - 2* var(--spacing))); 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Daniel Baulig 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 | -------------------------------------------------------------------------------- /src/ui/components/WifiSelectionComponent.module.css: -------------------------------------------------------------------------------- 1 | .row { 2 | display: grid; 3 | grid-template-columns: 1fr 0fr 0fr; 4 | gap: 0.5rem; 5 | padding: 0.5rem 0.5rem; 6 | cursor: pointer; 7 | } 8 | 9 | .row.insecure { 10 | grid-template-columns: 1fr 0fr; 11 | } 12 | 13 | .row:hover { 14 | background-color: var(--primary-background-30); 15 | } 16 | 17 | .radio:checked + .row { 18 | background-color: var(--primary-color); 19 | } 20 | 21 | .list { 22 | flex-basis: 100%; 23 | list-style-type: none; 24 | padding: 0.5rem; 25 | margin: 0 0.5rem; 26 | border: 1px solid black; 27 | max-height: 10.5rem; 28 | overflow-y: scroll; 29 | background-color: var(--card-background); 30 | } 31 | 32 | .radio { 33 | opacity: 0; 34 | position: absolute; 35 | pointer-events: none; 36 | } 37 | 38 | .password { 39 | flex-basis: 100%; 40 | background-color: var(--card-background); 41 | margin: 1rem 0.5rem 0; 42 | border: 1px solid black; 43 | font-size: 1.5rem; 44 | padding: 0.25rem 1rem; 45 | } 46 | 47 | .password::placeholder { 48 | font-style: italic; 49 | } 50 | 51 | .main { 52 | display: flex; 53 | flex-wrap: wrap; 54 | flex-basis: 100%; 55 | } 56 | 57 | .drawer { 58 | flex-basis: 100%; 59 | } 60 | -------------------------------------------------------------------------------- /src/isPrivateAddressSpace.js: -------------------------------------------------------------------------------- 1 | function stringAddressToBinaryAddress(s) { 2 | const bytes = s.split('.').map(BigInt); 3 | return ( 4 | (bytes[0] << 24n) + 5 | (bytes[1] << 16n) + 6 | (bytes[2] << 8n) + 7 | bytes[3] 8 | ); 9 | } 10 | 11 | function cidrSuffixToBitmask(suffix) { 12 | const suffixN = BigInt(suffix); 13 | return (2n ** suffixN << (32n - suffixN)); 14 | } 15 | 16 | function binaryAddressToStringAddress(addrN) { 17 | return [ 18 | String((addrN >> 24n) & 255n), 19 | String((addrN >> 16n) & 255n), 20 | String((addrN >> 8n) & 255n), 21 | String(addrN & 255n) 22 | ].join('.'); 23 | } 24 | 25 | // We are not dealing with IPv6 for now 26 | const privateAddressSpaceCIDRs = [ 27 | '10.0.0.0/8', 28 | '100.64.0.0/10', 29 | '172.16.0.0/12', 30 | '192.168.0.0/16', 31 | '169.254.0.0/16', 32 | ].map( 33 | cidr => cidr.split('/') 34 | ).map( 35 | ([netAddr, suffix]) => [ 36 | stringAddressToBinaryAddress(netAddr), 37 | cidrSuffixToBitmask(suffix), 38 | ] 39 | ); 40 | 41 | export default function isPrivateAddressSpace(addr) { 42 | const addrN = stringAddressToBinaryAddress(addr); 43 | return privateAddressSpaceCIDRs.some( 44 | ([netAddrN, netMask]) => (netAddrN & netMask) === (addrN & netMask) 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/ui/components/entities/LightColorComponent.jsx: -------------------------------------------------------------------------------- 1 | import {useState} from 'react'; 2 | 3 | import { colorComponent } from './LightColorComponent.module.css'; 4 | 5 | import ColorModeInput from './inputs/ColorModeInput'; 6 | import RGBInput from './inputs/RGBInput'; 7 | import ColorTemperatureInput from './inputs/ColorTemperatureInput'; 8 | 9 | export default function LightColorComponent({colorMode, color, colorTemp, onTurnOn}) { 10 | const [currentColorMode, setColorMode] = useState(colorMode); 11 | let colorControl = null; 12 | if (currentColorMode === 'rgb') { 13 | colorControl = onTurnOn({r: color.red, g: color.green, b: color.blue})} 18 | />; 19 | } 20 | if (currentColorMode === 'color_temp') { 21 | colorControl = onTurnOn({color_temp: value})} 24 | />; 25 | } 26 | 27 | return
28 | Color: 29 | { 30 | console.log('Color Mode', v); 31 | setColorMode(v) 32 | }}/> 33 | 34 | {colorControl} 35 |
; 36 | } 37 | -------------------------------------------------------------------------------- /src/ui/components/entities/FanEntity.jsx: -------------------------------------------------------------------------------- 1 | import EntityCard from '../EntityCard'; 2 | import ToggleInput from './inputs/ToggleInput'; 3 | import RangeInput from './inputs/RangeInput'; 4 | 5 | import useEntityState from './useEntityState'; 6 | import getEntityLabel from './getEntityLabel'; 7 | 8 | import { speed, fan, speedFan } from './FanEntity.module.css' 9 | 10 | export default function FanEntity({entity}) { 11 | const state = useEntityState(entity); 12 | const isOn = state.state === 'ON'; 13 | const isSpeedFan = 'speed_level' in state; 14 | 15 | let speedInput; 16 | if (isOn && isSpeedFan) { 17 | speedInput = 18 |
  • 19 | 20 | entity.turnOn({speed_level: value})} 25 | /> 26 | 27 |
  • ; 28 | } 29 | 30 | return 31 |
      32 |
    • 33 | entity.toggle()} 36 | /> 37 |
    • 38 | {speedInput} 39 |
    40 |
    ; 41 | } 42 | -------------------------------------------------------------------------------- /src/main.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | /* margin: 0; 4 | padding: 0; */ 5 | } 6 | 7 | body { 8 | --primary-color: lightsteelblue; 9 | --active-color: steelblue; 10 | --primary-background: lightslategray; 11 | --primary-background-30: #d5dadf; /* 30% lightslategray */ 12 | --card-background: #fbfbfb; 13 | --section-background: #f1f1f1; 14 | --box-shadow: 2px 2px 4px 2px lightslategray; 15 | --card-background-warning: darksalmon; 16 | --focus-outline: 4px dotted var(--primary-background); 17 | --focus-outline-offset: -4px; 18 | font-size: 16px; 19 | margin: 1rem 2rem; 20 | background-color: var(--primary-background); 21 | display: flex; 22 | justify-content: center; 23 | font-family: system-ui; 24 | } 25 | 26 | body *:focus-visible { 27 | outline: var(--focus-outline); 28 | outline-offset: var(--focus-outline-offset) 29 | } 30 | 31 | body > div { 32 | flex-grow: 1; 33 | max-width: 400px; 34 | } 35 | 36 | h1 { 37 | font-size: 2rem; 38 | } 39 | 40 | h3 { 41 | font-size: 1rem; 42 | font-weight: 600; 43 | } 44 | 45 | button { 46 | background-color: var(--primary-color); 47 | border-radius: 8px; 48 | min-height: 3rem; 49 | color: black; 50 | font-family: inherit; 51 | font-size: inherit; 52 | } 53 | 54 | button:disabled { 55 | background-color: var(--primary-background-30); 56 | } 57 | -------------------------------------------------------------------------------- /src/ui/Drawer.module.css: -------------------------------------------------------------------------------- 1 | .drawer { 2 | display: grid; 3 | } 4 | 5 | .drawer.vertical { 6 | grid-template-rows: 1fr; 7 | } 8 | 9 | .content { 10 | overflow: hidden; 11 | display: flex; 12 | flex-wrap: wrap; 13 | justify-content: center; 14 | } 15 | 16 | .drawer.vertical:global(.animation-enter) { 17 | grid-template-rows: 0fr; 18 | } 19 | 20 | .drawer.vertical:global(.animation-enter-active) { 21 | grid-template-rows: 1fr; 22 | transition: grid-template-rows 1000ms ease-in-out; 23 | } 24 | 25 | .drawer.vertical:global(.animation-exit) { 26 | transition: grid-template-rows 500ms ease-in-out; 27 | grid-template-rows: 1fr; 28 | } 29 | 30 | .drawer.vertical:global(.animation-exit-active) { 31 | grid-template-rows: 0fr; 32 | } 33 | 34 | .drawer.vertical:global(.animation-exit-done) { 35 | grid-template-rows: 0fr; 36 | } 37 | 38 | .drawer.horizontal { 39 | grid-template-columns: 1fr; 40 | } 41 | 42 | .drawer.horizontal:global(.animation-enter) { 43 | grid-template-columns: 0fr; 44 | } 45 | 46 | .drawer.horizontal:global(.animation-enter-active) { 47 | grid-template-columns: 1fr; 48 | transition: grid-template-columns 1000ms ease-in-out; 49 | } 50 | 51 | .drawer.horizontal:global(.animation-exit) { 52 | transition: grid-template-columns 500ms ease-in-out; 53 | grid-template-columns: 1fr; 54 | } 55 | 56 | .drawer.horizontal:global(.animation-exit-active) { 57 | grid-template-columns: 0fr; 58 | } 59 | 60 | .drawer.horizontal:global(.animation-exit-done) { 61 | grid-template-columns: 0fr; 62 | } 63 | -------------------------------------------------------------------------------- /src/ui/components/SerialConnectionList.jsx: -------------------------------------------------------------------------------- 1 | import SerialConnectionCard from './SerialConnectionCard'; 2 | import betterSerial from '../../BetterSerialPorts'; 3 | import { useState, useEffect } from 'react'; 4 | 5 | import { list } from './SerialConnectionList.module.css'; 6 | 7 | const portKeys = new WeakMap(); 8 | let currentPortKey = 0; 9 | 10 | function getPortKey(port) { 11 | let key = portKeys.get(port); 12 | if (key === undefined) { 13 | key = currentPortKey++; 14 | portKeys.set(port, key); 15 | } 16 | 17 | return key; 18 | } 19 | 20 | function useSerialPorts() { 21 | const serial = betterSerial; 22 | 23 | if (!serial) { 24 | return []; 25 | } 26 | 27 | const [ports, setPorts] = useState([]); 28 | 29 | async function updatePorts() { 30 | const ports = await serial.getPorts() 31 | setPorts(ports); 32 | } 33 | 34 | useEffect(() => { 35 | updatePorts(); 36 | serial.addEventListener('connect', () => { 37 | updatePorts(); 38 | }); 39 | serial.addEventListener('disconnect', () => { 40 | updatePorts(); 41 | }); 42 | 43 | return () => { 44 | serial.removeEventListener('connect', updatePorts); 45 | serial.removeEventListener('disconnect', updatePorts); 46 | setPorts([]); 47 | }; 48 | }, []); 49 | 50 | return ports; 51 | } 52 | 53 | export default function SerialConnectionList({showPort}) { 54 | const ports = useSerialPorts(); 55 | 56 | if (!ports.length) { 57 | return null; 58 | } 59 | 60 | return
    61 | {ports.map((port) => )} 66 |
    ; 67 | } 68 | -------------------------------------------------------------------------------- /src/ui/components/entities/LightComponent.jsx: -------------------------------------------------------------------------------- 1 | import BrightnessInput from './inputs/BrightnessInput'; 2 | import LightColorComponent from './LightColorComponent'; 3 | import useEntityState from './useEntityState'; 4 | import iif from './../../../iif'; 5 | 6 | import { 7 | lightComponent, 8 | brightness, 9 | state as stateClass 10 | } from './LightComponent.module.css'; 11 | 12 | export default function LightComponent({ 13 | entity, 14 | }) { 15 | const state = useEntityState(entity); 16 | 17 | const buttons = <> 18 | 19 | 20 | 21 | ; 22 | 23 | let controls = null; 24 | if (state.state === 'ON') { 25 | controls = <> 26 | {iif('color' in state,
  • 27 | 33 |
  • )} 34 | {iif('brightness' in state,
  • 35 |
    36 | Brightness 37 | entity.turnOn({brightness: value})} 40 | /> 41 |
    42 |
  • )} 43 | ; 44 | } 45 | 46 | return
    47 | {state.name || entity.slug} 48 |
      49 |
    • 50 |
      51 | State 52 |

      {state.state}

      53 | {buttons} 54 |
      55 |
    • 56 | {controls} 57 |
    58 |
    ; 59 | } 60 | -------------------------------------------------------------------------------- /src/ui/components/entities/inputs/ResponsiveInput.jsx: -------------------------------------------------------------------------------- 1 | import {useState, useRef, useEffect} from 'react'; 2 | 3 | // React does weird things with input elements and mapps their `input` 4 | // events onto the onChange handler while dropping the native change events 5 | // entirely. 6 | // However, the input spec says that input events fire while the user 7 | // interacts with the input element and the change event fires when the 8 | // interaction completes. E.g. 9 | // - Input fires while typing into a text input and change fires when the 10 | // element blurs. 11 | // - Input fires while dragging around a range input and change fires when 12 | // the control nob is released 13 | // - Input fires while clicking and dragging around a color input and change 14 | // fires when the color input is closed. 15 | // ResponsiveInput is a wrapper around Reacts input element that will 16 | // control the input element and update it's current state, but only report 17 | // the change up using onChange once the interaction has completed. 18 | export default function ResponsiveInput({onChange, value, ...props}) { 19 | const [interactionValue, setValue] = useState(value); 20 | const interactingRef = useRef(false); 21 | const inputRef = useRef(null); 22 | 23 | useEffect(() => { 24 | const input = inputRef.current; 25 | const listener = (event) => { 26 | interactingRef.current = false; 27 | onChange(event.target.value); 28 | } 29 | input.addEventListener('change', listener); 30 | return () => { 31 | input.removeEventListener('change', listener); 32 | } 33 | }, [inputRef.current]); 34 | 35 | return { 40 | interactingRef.current = true; 41 | setValue(event.target.value) 42 | }} 43 | />; 44 | } 45 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ -f .script-env ] 4 | then 5 | set -a 6 | source .script-env 7 | set +a 8 | fi 9 | 10 | JSON_PARSE="const json = JSON.parse(require('fs').readFileSync('package.json')); console.log(\`\${json.name} \${json.version}\`);" 11 | read -r NAME VERSION <<< $(node -e "$JSON_PARSE") 12 | 13 | echo "Releasing a new version of $NAME" 14 | 15 | read -p "Version [$VERSION]: " -r 16 | VERSION=${REPLY:-$VERSION} 17 | VERSION_LABEL="v$VERSION" 18 | 19 | echo 20 | echo "Releasing version $VERSION_LABEL" 21 | read -p "Are you sure? " -n 1 -r 22 | if [[ $REPLY =~ ^[^Yy]?$ ]] 23 | then 24 | echo 25 | echo "Aborted." 26 | exit 1 27 | fi 28 | 29 | echo "Working..." 30 | 31 | JSON_REPLACE_VERSION="((fs) => fs.writeFileSync('package.json', JSON.stringify({...JSON.parse(fs.readFileSync('package.json')), version: '$VERSION'}, null, 2)))(require('fs'))" 32 | node -e "$JSON_REPLACE_VERSION" 33 | echo "Updated package.json" 34 | 35 | git commit --allow-empty -am "Release $VERSION_LABEL" 36 | if [ $? -ne 0 ]; then 37 | echo "Failed creating release commit. Aborting." 38 | exit 1 39 | fi 40 | echo "Created release commit" 41 | 42 | git tag -a "$VERSION_LABEL" -m "Release $VERSION_LABEL" 43 | if [ $? -ne 0 ]; then 44 | echo "Failed creating release tag. Aborting." 45 | exit 1 46 | fi 47 | echo "Created release tag" 48 | 49 | git push --follow-tags 50 | if [ $? -ne 0 ]; then 51 | echo "Failed pushing release commit and tag. Aborting." 52 | exit 1 53 | fi 54 | echo "Pushed release commit and tag" 55 | 56 | gh release create "$VERSION_LABEL" --generate-notes 57 | if [ $? -ne 0 ]; then 58 | echo "Failed creating Github release. Aborting." 59 | exit 1 60 | fi 61 | echo "Created Github release" 62 | 63 | npm publish 64 | if [ $? -ne 0 ]; then 65 | echo "Failed publishing package to NPM. Aborting." 66 | exit 1 67 | fi 68 | echo "Published package to NPM" 69 | 70 | echo 71 | echo "Done." 72 | -------------------------------------------------------------------------------- /src/ui/Header.module.css: -------------------------------------------------------------------------------- 1 | .header { 2 | position: relative; 3 | display: flex; 4 | align-items: center; 5 | } 6 | 7 | .title { 8 | flex-grow: 1; 9 | font-family: sans-serif; 10 | letter-spacing: -1px; 11 | font-size: 2rem; 12 | } 13 | 14 | .menuToggle { 15 | opacity: 0; 16 | z-index: -1; 17 | position: absolute; 18 | } 19 | 20 | .menuToggle:focus-visible ~ .menuToggleLabel { 21 | outline: var(--focus-outline); 22 | outline-offset: calc(var(--focus-outline-offset) - 3px); 23 | } 24 | 25 | nav.menu { 26 | box-shadow:inset 0px 0px 0px 2px black; 27 | position: absolute; 28 | right: 0; 29 | min-height: 3rem; 30 | min-width: 3rem; 31 | border-radius: 1.5rem; 32 | background-color: var(--primary-color); 33 | display: flex; 34 | align-items: center; 35 | justify-content: right; 36 | } 37 | 38 | ul.menu { 39 | list-style-type: none; 40 | padding: 0; 41 | margin: 0; 42 | display: flex; 43 | overflow: hidden; 44 | } 45 | 46 | .drawer { 47 | display: grid; 48 | transition: grid-template-columns 300ms ease-in-out; 49 | grid-template-columns: 0fr; 50 | } 51 | 52 | .menu button { 53 | border: none; 54 | background-color: inherit; 55 | min-width: 3rem;; 56 | min-height: 3rem; 57 | border-radius: 1.5rem; 58 | cursor: pointer; 59 | display: flex; 60 | align-items: center; 61 | justify-content: center; 62 | } 63 | 64 | .menu button:focus-visible { 65 | outline: var(--focus-outline); 66 | outline-offset: calc(var(--focus-outline-offset) - 3px); 67 | } 68 | 69 | .menuToggle:checked + .drawer { 70 | grid-template-columns: 1fr; 71 | } 72 | 73 | .menuToggleLabel { 74 | cursor: pointer; 75 | border: 3px outset black; 76 | border-radius: 1.5rem; 77 | min-width: 3rem; 78 | min-height: 3rem; 79 | display: flex; 80 | justify-content: center; 81 | align-items: center; 82 | } 83 | 84 | .menuToggleLabel svg { 85 | transition: transform 300ms ease-out; 86 | } 87 | 88 | .menuToggle:checked ~ .menuToggleLabel svg { 89 | transform: rotate(-225deg); 90 | } 91 | -------------------------------------------------------------------------------- /src/ui/components/entities/CoverEntity.jsx: -------------------------------------------------------------------------------- 1 | import EntityCard from '../EntityCard'; 2 | import EntitySection from '../EntitySection'; 3 | import RangeInput from './inputs/RangeInput'; 4 | 5 | import useEntityState from './useEntityState'; 6 | import getEntityLabel from './getEntityLabel'; 7 | 8 | import { useState, useEffect } from 'react'; 9 | import { tilt, position, state as stateClass } from './CoverEntity.module.css'; 10 | 11 | export default function CoverEntity({entity}) { 12 | const state = useEntityState(entity); 13 | const [seenPositionTrait, setSeenPositionTrait] = useState(0 < state.value && state.value < 1); 14 | 15 | const currentState = state.current_operation !== 'IDLE' ? 16 | state.current_operation : 17 | state.state; 18 | 19 | useEffect(() => { 20 | if (seenPositionTrait) { 21 | return; 22 | } 23 | if (0 < state.position && state.position < 1) { 24 | setSeenPositionTrait(true); 25 | } 26 | }, [state.position]); 27 | 28 | const buttons = <> 29 | 30 | 31 | 32 | ; 33 | 34 | let tiltControls; 35 | if ('tilt' in state) { 36 | tiltControls = 37 | entity.set({tilt})} 44 | /> 45 | ; 46 | } 47 | 48 | let positionControls; 49 | if ('position' in state || seenPositionTrait) { 50 | positionControls = entity.set({position})} 57 | /> 58 | } 59 | 60 | return 61 | 62 |

    {currentState}

    63 | {positionControls} 64 | {buttons} 65 |
    66 | {tiltControls} 67 |
    ; 68 | } 69 | -------------------------------------------------------------------------------- /src/ui/components/ImprovWifi.jsx: -------------------------------------------------------------------------------- 1 | import Icon from '@mdi/react' 2 | import Spinner from '../Spinner'; 3 | import EntityCard from './EntityCard'; 4 | import EntitySection from './EntitySection'; 5 | import WifiSelectionComponent from './WifiSelectionComponent'; 6 | 7 | import iif from '../../iif'; 8 | import css from '../css'; 9 | import isPrivateAddressSpace from '../../isPrivateAddressSpace'; 10 | import createAddHostURL from '../../createAddHostURL'; 11 | 12 | import { mdiWifiCheck, mdiWifiCog, mdiWifiCancel } from '@mdi/js'; 13 | import { useState } from 'react'; 14 | 15 | import { flex, flexFill } from '../utility.module.css'; 16 | import { link } from './ImprovWifi.module.css'; 17 | 18 | const ipAddressRegExp = /^https?:\/\/([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})(:[0-9]+)?$/; 19 | 20 | export default function ImprovWifi({ 21 | initializing, 22 | error, 23 | provisioning, 24 | initialized, 25 | nextUrl, 26 | scanning, 27 | provisioned, 28 | ssids, 29 | improv 30 | }) { 31 | const [ isShowingWifiDialog, setShowWifiDialog] = useState(false); 32 | 33 | 34 | if (!initialized && !initializing) { 35 | return <> 36 | 41 |

    No Improv detected

    42 | ; 43 | } 44 | 45 | if (!initialized || provisioning) { 46 | return ; 47 | } 48 | 49 | if (isShowingWifiDialog) { 50 | return setShowWifiDialog(false)} 54 | onConnect={async (ssid, password) => { 55 | setShowWifiDialog(false); 56 | await improv.provision(ssid, password, 60000); 57 | }} 58 | /> 59 | } 60 | 61 | const match = ipAddressRegExp.exec(nextUrl); 62 | const addViaWifi = ( 63 | nextUrl.startsWith(location.href) || 64 | (match && isPrivateAddressSpace(match[1])) 65 | ); 66 | 67 | const visitUrl = match ? createAddHostURL(match[1]) : nextUrl; 68 | 69 | return <> 70 | 75 | {iif(error && !provisioned, 76 |

    Provisioning failed.

    77 | )} 78 | 86 | {iif(nextUrl, 87 | 92 | 93 | 94 | )} 95 | ; 96 | } 97 | -------------------------------------------------------------------------------- /src/ui/components/DrawerCard.jsx: -------------------------------------------------------------------------------- 1 | import Drawer from '../Drawer'; 2 | 3 | import { useReducer, useState, useEffect, useCallback, useRef } from 'react'; 4 | 5 | import { drawer, card, header, title as titleClass } from './DrawerCard.module.css'; 6 | 7 | function DrawerCardHeader({title, glyph, onToggleDrawer, onMouseDown, children}) { 8 | return
    {glyph} 9 | 17 | {children} 18 |
    ; 19 | } 20 | 21 | export default function DrawerCard({open, glyph, onToggleDrawer, onBeginOpening, onDoneClosing, title, menu, children, onDragStart, onDragEnd}) { 22 | const [{closing, closed}, dispatch] = useReducer((state, action) => { 23 | switch (action.type) { 24 | case 'finishClosing': 25 | return {closing: false, closed: true}; 26 | case 'beginClosing': 27 | return {closing: true, closed: false} 28 | case 'beginOpening': 29 | return {closing: false, closed: false} 30 | } 31 | throw new Error('Invalid action type'); 32 | }, {closing: false, closed: !open}); 33 | 34 | // Allow for "uncontrolled" use of DrawerCard 35 | // Basically functions like input controlled/uncontrolled 36 | // If a onToggleDrawer callback is provided, it is responsible 37 | // for switching the open state on DrawerCard, otherwise DrawerCard 38 | // will use open just for initial rendering and will manage the 39 | // drawer open/close state internally. 40 | const [uncontrolledOpen, setUncontrolledOpen] = useState(open); 41 | const onUncontrolledToggleDrawer = useCallback(() => setUncontrolledOpen((state) => !state)); 42 | if (!onToggleDrawer) { 43 | onToggleDrawer = onUncontrolledToggleDrawer; 44 | open = uncontrolledOpen; 45 | } 46 | const [isDraggable, setDraggable] = useState(false); 47 | const [dragging, setDragging] = useState(false); 48 | const draggableRef = useRef(null); 49 | 50 | useEffect(() => { 51 | if (open) { 52 | onBeginOpening?.(); 53 | dispatch({ type: 'beginOpening' }); 54 | } else if (!closed) { 55 | dispatch({ type: 'beginClosing' }); 56 | } 57 | }, [open]); 58 | 59 | return
    { 63 | setDragging(true) 64 | onDragStart(event) 65 | }} 66 | onDragEnd={(event) => { 67 | setDragging(false); 68 | onDragEnd(event) 69 | }} 70 | > 71 | setDraggable(true)} 76 | > 77 | {menu} 78 | 79 | { 83 | onDoneClosing?.(); 84 | dispatch({type: 'finishClosing'}); 85 | }} 86 | > 87 | {children} 88 | 89 |
    ; 90 | } 91 | -------------------------------------------------------------------------------- /src/ui/Header.jsx: -------------------------------------------------------------------------------- 1 | import Icon from '@mdi/react'; 2 | import Drawer from './Drawer'; 3 | import SerialConnectButton from './SerialConnectButton'; 4 | 5 | import { mdiPlusThick, mdiWifiPlus, mdiUsb } from '@mdi/js'; 6 | import { useState, useId, useRef } from 'react'; 7 | import { drawer, header, menu, menuToggle, menuToggleLabel, title as titleClass } from './Header.module.css'; 8 | 9 | import { title } from '../config'; 10 | 11 | function Header({onAddController, onConnectSerialPort}) { 12 | // We want the menu items to be ordered in reverse 13 | // in their grid. I.e. the logically first item 14 | // should render on the right. 15 | // I tried using flex-direction: row-reverse first 16 | // but that caused issues with the 0fr -> 1fr animation 17 | // then going from right to left, instead of left to right. 18 | const order = ((i) => () => i = i - 1)(0); 19 | 20 | const [isMenuOpen, setMenuOpen] = useState(false); 21 | const id = useId(); 22 | const checkBoxRef = useRef(null); 23 | 24 | // We'll remove the menu buttons from tab order while 25 | // the menu is closed 26 | const menuButtonTabIndex = isMenuOpen ? 0 : -1; 27 | 28 | return
    29 |

    {title}

    30 | 87 |
    ; 88 | } 89 | 90 | export default Header; 91 | -------------------------------------------------------------------------------- /src/BetterSerialPorts.js: -------------------------------------------------------------------------------- 1 | // Sadly the web APIs for SerialPort and Serial are not strong enough 2 | // to stand by themselves. I need to wrap them for some basic management 3 | // tasks. For example there's no even on Serial that notifies that a new 4 | // SerialPort become available (e.g. via a requestPort call) if it wasn't 5 | // just physically connected to the host. Similarly, there's no even on 6 | // Serial that a SerialPort became unavailable (i.e. was forgotten). 7 | // SerialPort instances also do not track if they are open or not or fire 8 | // events when they get opened or closed. 9 | 10 | const betterSerial = navigator.serial; 11 | 12 | function prototype(o) { 13 | return Object.getPrototypeOf(o); 14 | } 15 | 16 | function makeBetterPortPatches(original) { 17 | return { 18 | connected: true, 19 | opened: false, 20 | better: true, 21 | 22 | dispatchEvent(event) { 23 | switch (event.type) { 24 | case 'connect': 25 | this.connected = true; 26 | break; 27 | case 'disconnect': 28 | this.opened = false; 29 | this.connected = false; 30 | break; 31 | } 32 | 33 | return original.dispatchEvent(event); 34 | }, 35 | async forget() { 36 | await original.forget(); 37 | this.dispatchEvent(new Event('disconnect', { bubbles: true })); 38 | }, 39 | async open(...args) { 40 | await original.open(...args); 41 | this.opened = true; 42 | this.dispatchEvent(new Event('open')); 43 | }, 44 | async close(...args) { 45 | await original.close(...args); 46 | this.opened = false; 47 | this.dispatchEvent(new Event('close')); 48 | } 49 | }; 50 | }; 51 | 52 | 53 | function makeBetter(port) { 54 | if (!port.better) { 55 | port = monkeypatch( 56 | port, 57 | makeBetterPortPatches 58 | ); 59 | } 60 | return port; 61 | } 62 | 63 | function monkeypatch(obj, getPatches) { 64 | const original = {}; 65 | const patches = getPatches(original); 66 | 67 | for (const [m, f] of Object.entries(patches)) { 68 | if (typeof f === 'function') { 69 | original[m] = obj[m].bind(obj); 70 | } 71 | obj[m] = f; 72 | } 73 | 74 | return obj; 75 | } 76 | 77 | if (betterSerial) { 78 | monkeypatch(betterSerial, (original) => { 79 | return { 80 | async getPorts() { 81 | const ports = await original.getPorts(); 82 | return ports.map(port => makeBetter(port)); 83 | }, 84 | async requestPort(...args) { 85 | const port = await original.requestPort(...args) 86 | // If the port is completely new we will need to fire 87 | // a connect event. But we can only tell before we make 88 | // it better, since makeBetter will set connected to true 89 | // given that the port is now obviously connected. 90 | const isFresh = !port.connected; 91 | const betterPort = makeBetter(port); 92 | if (isFresh) { 93 | betterPort.dispatchEvent(new Event('connect', { bubbles: true })); 94 | } 95 | 96 | return betterPort; 97 | }, 98 | }; 99 | }); 100 | 101 | betterSerial.addEventListener('connect', (event) => { 102 | makeBetter(event.target) 103 | }); 104 | } 105 | 106 | export default betterSerial; 107 | -------------------------------------------------------------------------------- /src/ControllerRegistry.js: -------------------------------------------------------------------------------- 1 | import { ESPHomeWebController } from 'esphome-web'; 2 | import FetchEventSource from './FetchEventSource'; 3 | 4 | function curry(C, arg) { 5 | return function(...args) { 6 | args.push(arg); 7 | return new C(...args); 8 | } 9 | } 10 | 11 | export default class ControllerRegistry extends EventTarget { 12 | #fetch; 13 | 14 | constructor(storageKey = "controllers", {fetch} = {}) { 15 | super(); 16 | this.storageKey = storageKey; 17 | this.#fetch = fetch; 18 | const json = JSON.parse(localStorage.getItem(storageKey)); 19 | this.hosts = json || []; 20 | this.#constructControllers(); 21 | } 22 | 23 | has(host) { 24 | return this.hosts.includes(host); 25 | } 26 | 27 | get(host) { 28 | return this.controllers[host]; 29 | } 30 | 31 | #onControllerError = (event) => { 32 | this.dispatchEvent(new CustomEvent('controllererror', {detail: event})); 33 | } 34 | 35 | #destroyController(host) { 36 | const controller = this.controllers[host]; 37 | controller.removeEventListener('error', this.#onControllerError); 38 | controller.destroy(); 39 | } 40 | 41 | #createController(host) { 42 | const controller = new ESPHomeWebController(host, { 43 | fetch: this.#fetch, 44 | EventSource: curry(FetchEventSource, {fetch: this.#fetch}) 45 | }); 46 | 47 | controller.addEventListener('error', this.#onControllerError); 48 | 49 | return controller; 50 | } 51 | 52 | #constructControllers() { 53 | if (!this.controllers) { 54 | this.controllers = {}; 55 | } 56 | this.hosts.forEach(host => { 57 | if (!(host in this.controllers)) { 58 | this.controllers[host] = this.#createController(host); 59 | } 60 | }); 61 | } 62 | 63 | #flush() { 64 | localStorage.setItem(this.storageKey, JSON.stringify(this.hosts)); 65 | } 66 | 67 | addHost(host) { 68 | if (this.has(host)) { 69 | return this.controllers[host]; 70 | } 71 | this.hosts.push(host); 72 | this.controllers[host] = this.#createController(host); 73 | this.#flush(); 74 | return this.controllers[host]; 75 | } 76 | 77 | insertHost(host, controller) { 78 | const hostIndex = this.hosts.indexOf(host); 79 | // Check if we alredy have this host 80 | if (hostIndex > -1) { 81 | // Remove host from current position if so 82 | this.hosts.splice(hostIndex, 1); 83 | } 84 | 85 | // Check if a controller was given to insert before and we can find it's position 86 | const insertIndex = controller ? this.hosts.indexOf(controller.host) : -1; 87 | if (insertIndex > -1) { 88 | // Insert host before given controller if so 89 | this.hosts.splice(insertIndex, 0, host); 90 | } else { 91 | // Otherwise append at end 92 | this.hosts.push(host); 93 | } 94 | 95 | // Check if we already have a controller for this host 96 | if (!this.controllers[host]) { 97 | // Create one if not 98 | this.controller[host] = this.#createController(host); 99 | } 100 | 101 | this.#flush(); 102 | 103 | return this.controllers[host]; 104 | } 105 | 106 | removeHost(host) { 107 | if (!this.has(host)) { 108 | return; 109 | } 110 | this.hosts = this.hosts.filter(v => v != host); 111 | this.#destroyController(host); 112 | delete this.controllers[host]; 113 | this.#flush(); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/ui/components/entities/ClimateEntity.jsx: -------------------------------------------------------------------------------- 1 | import EntityCard from '../EntityCard'; 2 | import NumberInput from './inputs/NumberInput'; 3 | import EntitySection from '../EntitySection'; 4 | import DropDownInput from './inputs/DropDownInput'; 5 | 6 | import useEntityState from './useEntityState'; 7 | import getEntityLabel from './getEntityLabel'; 8 | 9 | import css from '../../css'; 10 | import iif from '../../../iif'; 11 | import { flex, flexFill } from '../../utility.module.css'; 12 | 13 | function HeatCoolControls({state, onLowChange, onHighChange}) { 14 | return (<> 15 | 22 | 29 | ); 30 | } 31 | 32 | function HeatControls({state, onChange}) { 33 | const heatSetPoint = iif( 34 | 'target_temperature' in state, 35 | state.target_temperature, 36 | state.target_temperature_low 37 | ); 38 | return ( 39 | 46 | ); 47 | } 48 | 49 | function CoolControls({state, onChange}) { 50 | const coolSetPoint = iif( 51 | 'target_temperature' in state, 52 | state.target_temperature, 53 | state.target_temperature_high 54 | ); 55 | return ( 56 | 63 | ); 64 | } 65 | 66 | export default function ClimateEntity({entity}) { 67 | const state = useEntityState(entity); 68 | 69 | const onLowChange = iif( 70 | 'target_temperature' in state, 71 | (target_temperature) => entity.set({target_temperature}), 72 | (target_temperature_low) => entity.set({target_temperature_low}), 73 | ); 74 | 75 | const onHighChange = iif( 76 | 'target_temperature' in state, 77 | (target_temperature) => entity.set({target_temperature}), 78 | (target_temperature_high) => entity.set({target_temperature_high}), 79 | ); 80 | 81 | let controls = <> 82 | 83 | entity.set({mode: event.target.value})} 88 | /> 89 | {iif( 90 | state.mode === 'HEAT_COOL', 91 | 96 | )} 97 | {iif( 98 | state.mode === 'HEAT', 99 | 100 | )} 101 | {iif( 102 | state.mode === 'COOL', 103 | 104 | )} 105 | 106 | ; 107 | 108 | return ( 109 | 110 | 111 |

    {state.state}

    112 |
    113 | Current Temperature 114 |

    {state.current_temperature}

    115 |
    116 |
    117 | {controls} 118 |
    119 | ); 120 | } 121 | 122 | 123 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import manifest from 'rollup-plugin-output-manifest'; 4 | import topLevelAwait from 'vite-plugin-top-level-await'; 5 | 6 | import fs from 'fs'; 7 | 8 | const commitHash = require('child_process') 9 | .execSync('git rev-parse --short HEAD') 10 | .toString(); 11 | 12 | function esphomeWebConfigVirtualModules() { 13 | const cssModule = 'virtual:custom.css'; 14 | const moduleIds = [cssModule]; 15 | function resolveId(id) { 16 | return `\0${id}`; 17 | } 18 | 19 | const esphomeWebConfigPath = 'esphome-web.json'; 20 | 21 | let esphomeWebConfig = {}; 22 | if (fs.existsSync(esphomeWebConfigPath)) { 23 | esphomeWebConfig = JSON.parse(fs.readFileSync(esphomeWebConfigPath)) 24 | } 25 | function generateCssModule() { 26 | const o = esphomeWebConfig.css || {}; 27 | return `body { \n${Object.keys(o).map(k => ` ${k}: ${o[k]};\n`).join('')}}`; 28 | } 29 | 30 | return { 31 | name: 'esphome-web-config-virtual-modules-plugin', 32 | resolveId(id) { 33 | if (moduleIds.includes(id)) { 34 | return resolveId(id); 35 | } 36 | }, 37 | transformIndexHtml(html) { 38 | if ('title' in esphomeWebConfig) { 39 | html = html.replace( 40 | /(.*?)<\/title>/, 41 | `<title>${esphomeWebConfig.title}` 42 | ); 43 | } 44 | if (esphomeWebConfig.originTrialTokens?.pnaPermissionPromptToken) { 45 | html = html.replace( 46 | '', 47 | ``, 48 | ); 49 | } 50 | if (esphomeWebConfig.originTrialTokens?.pnaNonSecureToken) { 51 | html = html.replace( 52 | '', 53 | ``, 54 | ); 55 | } 56 | 57 | html = html.replace( 58 | /.*.*\n/g, 59 | '', 60 | ); 61 | 62 | return html; 63 | }, 64 | async handleHotUpdate({file, modules, read, server}) { 65 | if (file.endsWith('esphome-web.json')) { 66 | // Refresh config cache 67 | esphomeWebConfig = JSON.parse(await read()); 68 | // Find module in module graph 69 | const mod = server.moduleGraph.getModuleById(resolveId(cssModule)); 70 | 71 | return [...modules, mod]; 72 | } 73 | }, 74 | load(id) { 75 | if (id === resolveId(cssModule)) { 76 | return generateCssModule(); 77 | } 78 | }, 79 | }; 80 | } 81 | 82 | export default defineConfig({ 83 | define: { 84 | __COMMIT_HASH__: JSON.stringify(commitHash), 85 | }, 86 | plugins: [ 87 | esphomeWebConfigVirtualModules(), 88 | react(), 89 | manifest({nameWithExt: false, filter: () => true}), 90 | topLevelAwait(), 91 | ], 92 | build: { 93 | manifest: 'vite-manifest.json', 94 | outDir: "./public", 95 | rollupOptions: { 96 | input: { 97 | app: "./index.html", 98 | sw: "./src/sw.js", 99 | }, 100 | output: { 101 | entryFileNames: assetInfo => assetInfo.name == "sw" ? "[name].js" : "assets/js/[name]-[hash].js", 102 | manualChunks(id) { 103 | if (id.includes('react')) { 104 | return 'react'; 105 | } 106 | }, 107 | }, 108 | }, 109 | }, 110 | publicDir: "./static", 111 | server: { 112 | headers: { 113 | 'Content-Security-Policy': 'treat-as-public-address', 114 | 'Service-Worker-Allowed': '/', 115 | }, 116 | }, 117 | }); 118 | -------------------------------------------------------------------------------- /src/ui/components/WifiSelectionComponent.jsx: -------------------------------------------------------------------------------- 1 | import Icon from '@mdi/react'; 2 | import { mdiWifiStrengthAlertOutline, mdiLock, mdiWifiStrength4, mdiWifiStrength3, mdiWifiStrength2, mdiWifiStrength1, mdiWifiStrengthOutline } from '@mdi/js'; 3 | import { useId, useState, useRef, useEffect } from 'react'; 4 | 5 | import iif from '../../iif'; 6 | import css from '../css'; 7 | 8 | import Drawer from '../Drawer'; 9 | import Spinner from '../Spinner'; 10 | import {flex} from '../utility.module.css'; 11 | 12 | import { 13 | row, 14 | list, 15 | radio, 16 | insecure, 17 | password as passwordClass, 18 | drawer, 19 | main, 20 | } from './WifiSelectionComponent.module.css'; 21 | 22 | function getRssiIcon(rssi) { 23 | if (rssi >= -55) { 24 | return mdiWifiStrength4; 25 | } 26 | if (rssi >= -66) { 27 | // Three 28 | return mdiWifiStrength3; 29 | } 30 | if (rssi >= -77) { 31 | // Two 32 | return mdiWifiStrength2; 33 | } 34 | if (rssi >= -88) { 35 | // One 36 | return mdiWifiStrength1; 37 | } 38 | 39 | return mdiWifiStrengthOutline; 40 | } 41 | 42 | function WifiSelectionRow({name, rssi, secured, radioGroup, onSelected}) { 43 | const id = useId(); 44 | const iconSize = 0.8; 45 | return <> 46 | { 52 | e.target.parentElement.scrollIntoView({block: 'nearest'}); 53 | onSelected({name, rssi, secured}); 54 | }} 55 | /> 56 | 61 | ; 62 | } 63 | 64 | export default function WifiSelectionComponent({ssids, scanning, onCancel, onConnect}) { 65 | const passwordRef = useRef(null); 66 | const [password, setPassword] = useState(''); 67 | const [promptPassword, setPromptPassword] = useState(false); 68 | const [ssid, setSsid] = useState(null); 69 | const id = useId(); 70 | 71 | return <> 72 |
    73 |
      74 | {ssids.sort((one, two) => two.rssi - one.rssi).map( 75 | (ssid, idx) =>
    • 76 | setSsid(ssid)} /> 77 |
    • 78 | )} 79 | {iif( 80 | ssids.length === 0, 81 |
    • { 82 | scanning ? 83 | : 84 | 85 | }
    • )} 86 |
    87 | passwordRef.current.focus()} 91 | > 92 | setPassword(e.target.value)} 99 | /> 100 | 101 |
    102 | 115 | 122 | 123 | 124 | } 125 | -------------------------------------------------------------------------------- /src/ui/components/useImprovSerial.js: -------------------------------------------------------------------------------- 1 | import { ImprovSerial } from 'improv-wifi-serial-sdk/src/serial'; 2 | 3 | import { useRef, useReducer } from 'react'; 4 | 5 | export default function useImprovSerial(port) { 6 | const improvRef = useRef(null); 7 | 8 | const [state, dispatch] = useReducer((state, action) => { 9 | console.log(action.type); 10 | switch(action.type) { 11 | case 'reset': { 12 | return { 13 | ...state, 14 | initialized: false, 15 | }; 16 | } 17 | case 'initialize_start': 18 | return { 19 | ...state, 20 | error: null, 21 | initializing: true, 22 | } 23 | case 'initialize_end': { 24 | const { improvState, nextUrl, info } = action; 25 | return { 26 | ...state, 27 | ...info, 28 | provisioned: improvState === 4, 29 | nextUrl, 30 | initialized: true, 31 | initializing: false, 32 | }; 33 | } 34 | case 'initialize_failed': { 35 | const { error } = action; 36 | return { 37 | ...state, 38 | initialized: false, 39 | initializing: false, 40 | error 41 | }; 42 | } 43 | case 'scan_start': 44 | return { ...state, scanning: true, error: null } 45 | case 'scan_end': 46 | return { ...state, scanning: false, ssids: action.ssids } 47 | case 'provision_start': 48 | return { ...state, provisioning: true, error: null }; 49 | case 'provision_end': { 50 | const { nextUrl } = action; 51 | return { 52 | ...state, 53 | nextUrl, 54 | provisioning: false, 55 | provisioned: true, 56 | }; 57 | } 58 | case 'provision_failed': { 59 | const { error } = action; 60 | return { 61 | ...state, 62 | error, 63 | provisioning: false, 64 | provisioned: false, 65 | nextUrl: null, 66 | }; 67 | } 68 | } 69 | throw new Error(`Invalid action ${action.type}`); 70 | }, {ssids: []}); 71 | 72 | async function initialize() { 73 | function onErrorChange(event) { 74 | console.log('Improv error change', event) 75 | } 76 | function onStateChange(event) { 77 | console.log('Improv state change', event) 78 | } 79 | 80 | function onDisconnect() { 81 | cleanup(); 82 | } 83 | 84 | function cleanup() { 85 | const improv = improvRef.current; 86 | if (!improv) { 87 | return; 88 | } 89 | improv.removeEventListener('disconnect', onDisconnect); 90 | improv.removeEventListener('state-changed', onStateChange); 91 | improv.removeEventListener('error-changed', onErrorChange); 92 | improvRef.current = null; 93 | } 94 | 95 | dispatch({ type: 'initialize_start' }); 96 | try { 97 | if (!port.writable || !port.readable) { 98 | await port.open({baudRate: useImprovSerial.baudRate}); 99 | } 100 | const improv = new ImprovSerial(port, console); 101 | improv.addEventListener('disconnect', onDisconnect, { once: true }); 102 | improv.addEventListener('state-change', onStateChange); 103 | improv.addEventListener('error-change', onErrorChange); 104 | improvRef.current = improv; 105 | 106 | const info = await improv.initialize(); 107 | dispatch({ 108 | type: 'initialize_end', 109 | info, 110 | nextUrl: improv.nextUrl, 111 | improvState: improv.state 112 | }); 113 | } catch(error) { 114 | console.error(error); 115 | cleanup(); 116 | dispatch({ type: 'initialize_failed', error }); 117 | } 118 | } 119 | 120 | async function withImprovInstance(fn) { 121 | const initialized = !!improvRef.current; 122 | if (!initialized) { 123 | console.log('initializing improv'); 124 | await initialize(); 125 | } 126 | try { 127 | console.log('executing withImprovInstance'); 128 | return await fn(); 129 | } finally { 130 | console.log('done'); 131 | if (!initialized && improvRef.current) { 132 | console.log('cleaning up'); 133 | await improvRef.current.close(); 134 | } 135 | } 136 | } 137 | 138 | return [state, { 139 | async finger(options) { 140 | if (options?.reset) { 141 | dispatch({ type: 'reset' }); 142 | } 143 | await withImprovInstance(async () => {}); 144 | }, 145 | async scan() { 146 | dispatch({ type: 'scan_start' }); 147 | return withImprovInstance(async () => { 148 | const ssids = await improvRef.current.scan(); 149 | dispatch({ type: 'scan_end', ssids }); 150 | }); 151 | }, 152 | async provision(ssid, password, timeout) { 153 | dispatch({ type: 'provision_start' }); 154 | return withImprovInstance(async () => { 155 | try { 156 | await improvRef.current.provision(ssid, password, timeout); 157 | } catch(error) { 158 | return dispatch({ type: 'provision_failed', error }); 159 | } 160 | dispatch({ 161 | type: 'provision_end', 162 | nextUrl: improvRef.current.nextUrl 163 | }); 164 | }); 165 | }, 166 | }]; 167 | } 168 | 169 | useImprovSerial.baudRate = 115200; 170 | -------------------------------------------------------------------------------- /src/FetchEventSource.js: -------------------------------------------------------------------------------- 1 | function isComment(value) { 2 | return value.startsWith(':'); 3 | } 4 | 5 | function isTermination(value) { 6 | // We split by \n, so we expect the empty line to be an empty string 7 | return value === ''; 8 | } 9 | 10 | const fieldRegex = /^([a-z]+):\s?(.*)$/; 11 | 12 | function isField(value) { 13 | return !!value.match(fieldRegex); 14 | } 15 | 16 | function getFieldNameAndValue(value) { 17 | const match = value.match(fieldRegex); 18 | return [match[1], match[2]]; 19 | } 20 | 21 | function createMessage() { 22 | return { 23 | data: [], 24 | event: 'message', 25 | }; 26 | } 27 | 28 | const READYSTATE_CONNECTING = 0; 29 | const READYSTATE_OPEN = 1; 30 | const READYSTATE_CLOSED = 2; 31 | 32 | export default class EventSource extends EventTarget { 33 | #preventCors = true; 34 | #abortController = new AbortController(); 35 | #fetch; 36 | #url; 37 | #lastEventId = ''; 38 | #retryTimeout = 5000; 39 | #retryRef = 0; 40 | 41 | constructor(url, {fetch} = {}) { 42 | super(); 43 | const { signal } = this.#abortController; 44 | this.#fetch = fetch || globalThis.fetch; 45 | this.#url = url; 46 | this.#connect(); 47 | } 48 | 49 | #getFetchHeaders() { 50 | if (!this.#preventCors) { 51 | return { 52 | 'Last-Event-ID': this.#lastEventId, 53 | 'Accept': 'text/event-stream', 54 | }; 55 | } 56 | return {}; 57 | } 58 | 59 | #getFetchOptions() { 60 | const { signal } = this.#abortController; 61 | return { 62 | headers: this.#getFetchHeaders(), 63 | signal, 64 | }; 65 | } 66 | 67 | #connect() { 68 | this.#cancelRetry(); 69 | this.readyState = READYSTATE_CONNECTING; 70 | this.#fetch.call(undefined, this.#url, this.#getFetchOptions()).then(this.#onResponse, this.#onFetchError); 71 | } 72 | 73 | close() { 74 | this.#abortController.abort(); 75 | this.#cancelRetry(); 76 | } 77 | 78 | #cancelRetry() { 79 | if (!this.#retryRef) { 80 | return; 81 | } 82 | clearTimeout(this.#retryRef); 83 | this.#retryRef = 0; 84 | } 85 | 86 | #retryConnection() { 87 | this.#cancelRetry(); 88 | console.log('retry connection'); 89 | this.#retryRef = setTimeout(() => { 90 | this.#connect(); 91 | }, this.#retryTimeout); 92 | this.readyState = READYSTATE_CONNECTING; 93 | } 94 | 95 | #closeConnection() { 96 | this.#cancelRetry(); 97 | this.readyState = READYSTATE_CLOSED; 98 | } 99 | 100 | #failConnection() { 101 | this.#closeConnection(); 102 | this.dispatchEvent(new Event('error')); 103 | } 104 | 105 | #onFetchError = (e) => { 106 | if (e.name === 'AbortError') { 107 | return this.#closeConnection(); 108 | } 109 | this.#failConnection(); 110 | } 111 | 112 | #onMessageComplete(message) { 113 | const dataField = message.data; 114 | const data = dataField.length > 0 ? dataField.join('\n') : null; 115 | const lastEventId = this.#lastEventId; 116 | const messageEvent = new MessageEvent(message.event, {data, lastEventId}); 117 | this.dispatchEvent(messageEvent); 118 | } 119 | 120 | #onConnected = () => { 121 | this.readyState = READYSTATE_OPEN; 122 | this.dispatchEvent(new Event('open')); 123 | } 124 | 125 | #onResponse = async (response) => { 126 | if (!response.ok) { 127 | return this.#failConnection(); 128 | } 129 | 130 | this.#onConnected(); 131 | 132 | try { 133 | const stream = response.body; 134 | const textStream = stream.pipeThrough(new TextDecoderStream()); 135 | const reader = textStream.getReader(); 136 | 137 | let message = createMessage(); 138 | do { 139 | const {done, value} = await reader.read(); 140 | if (done) { 141 | break; 142 | } 143 | 144 | 145 | const normalized = value.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); 146 | 147 | if (!normalized.endsWith('\n')) { 148 | console.warn('Message wasn\'t terminated by a newline character'); 149 | continue; 150 | } 151 | const lines = normalized.substr(0, normalized.length - 1).split('\n'); 152 | 153 | lines.forEach((line) => { 154 | if (isComment(line)) { 155 | return; 156 | } 157 | 158 | if (isField(line)) { 159 | const [fieldName, fieldValue] = getFieldNameAndValue(line); 160 | 161 | switch (fieldName) { 162 | case 'data': 163 | message.data.push(fieldValue); 164 | break; 165 | case 'id': 166 | this.#lastEventId = fieldValue; 167 | break; 168 | case 'retry': 169 | this.#retryTimeout = parseInt(fieldValue); 170 | break; 171 | case 'event': 172 | message.event = fieldValue; 173 | break; 174 | } 175 | return; 176 | } 177 | 178 | if (isTermination(line)) { 179 | this.#onMessageComplete(message); 180 | message = createMessage(); 181 | return; 182 | } 183 | }); 184 | } while(true); 185 | console.warn('Reached end of stream'); 186 | // Only exits out of while loop if end of stream was reached. 187 | this.#retryConnection(); 188 | } catch(e) { 189 | if (e.name === 'AbortError') { 190 | return this.#closeConnection(); 191 | } 192 | this.#retryConnection(); 193 | } 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/ui/components/SerialConnectionCard.jsx: -------------------------------------------------------------------------------- 1 | import Icon from '@mdi/react' 2 | import DrawerCard from './DrawerCard'; 3 | import ImprovWifi from './ImprovWifi'; 4 | import FirmwareFlasher from './FirmwareFlasher'; 5 | import EntitySection from './EntitySection'; 6 | import EntityCard from './EntityCard'; 7 | 8 | import useImprovSerial from './useImprovSerial'; 9 | import iif from '../../iif'; 10 | import sleep from '../../sleep'; 11 | 12 | import { useReducer, useEffect, useState } from 'react'; 13 | import { title as appTitle } from '../../config'; 14 | 15 | import { mdiUsb, mdiCloseThick } from '@mdi/js'; 16 | 17 | import { closeButton } from './SerialConnectionCard.module.css'; 18 | import { flex, flexFill } from '../utility.module.css'; 19 | 20 | function useBetterSerialPort(port) { 21 | function initialize(port) { 22 | return { 23 | connected: port.connected, 24 | opened: port.opened, 25 | }; 26 | } 27 | const [state, dispatch] = useReducer((state, action) => { 28 | switch (action.type) { 29 | case 'open': 30 | const info = port.getInfo(); 31 | return { 32 | ...state, 33 | connected: true, 34 | opened: true, 35 | vendorId: info.usbVendorId, 36 | productId: info.usbProductId, 37 | }; 38 | case 'close': 39 | return { 40 | ...state, 41 | opened: false, 42 | }; 43 | case 'initialize': 44 | return initialize(action.port); 45 | case 'disconnect': 46 | return { 47 | ...state, 48 | opened: false, 49 | connected: false, 50 | }; 51 | } 52 | }, port, initialize); 53 | useEffect(() => { 54 | function onOpen() { 55 | dispatch({ type: 'open' }); 56 | } 57 | function onClose() { 58 | dispatch({ type: 'close' }); 59 | } 60 | function onDisconnect() { 61 | dispatch({ type: 'disconnect' }); 62 | } 63 | 64 | port.addEventListener('open', onOpen); 65 | port.addEventListener('close', onClose); 66 | port.addEventListener('disconnect', onDisconnect); 67 | 68 | dispatch({ type: 'initialize', port }); 69 | 70 | return () => { 71 | port.removeEventListener('open', onOpen); 72 | port.removeEventListener('close', onClose); 73 | port.removeEventListener('disconnect', onDisconnect); 74 | }; 75 | }, [port]); 76 | 77 | return state; 78 | } 79 | 80 | export default function SerialConnectionCard({port, onRemove, open}) { 81 | const { 82 | opened, 83 | connected, 84 | vendorId, 85 | productId, 86 | } = useBetterSerialPort(port); 87 | const [ improvState, improv ] = useImprovSerial(port); 88 | const [ error, setError ] = useState(null); 89 | const { name, chipFamily, firmware, version } = improvState; 90 | 91 | useEffect(() => { 92 | (async () => { 93 | if (!port.opened && !open) { 94 | // If the port isn't open yet and the card won't render 95 | // opened anyway briefly open the port to read vendorId 96 | // and productId 97 | try { 98 | await port.open({baudRate: useImprovSerial.baudRate}) 99 | await port.close(); 100 | } catch(error) { 101 | console.error(error); 102 | setError(error); 103 | } 104 | } 105 | })(); 106 | return async () => { 107 | if (port.opened) { 108 | await port.close(); 109 | } 110 | }; 111 | }, [port]); 112 | 113 | const title = name || (vendorId && productId && `usb-${vendorId}-${productId}`) || 'unidentified serial device'; 114 | 115 | const menu = ; 122 | 123 | let content = <> 124 | 125 | {iif(chipFamily && firmware && version,
    126 | {iif(chipFamily, 127 |

    {chipFamily}

    128 |
    )} 129 | {iif(firmware, 130 |

    {firmware}

    131 |
    )} 132 | {iif(version, 133 |

    {version}

    134 |
    )} 135 |
    )} 136 | { 140 | try { 141 | await improv.finger({ reset: true }); 142 | } catch(error) { 143 | console.error(error); 144 | setError(error); 145 | } 146 | }}/> 147 |
    148 | 149 | 150 | 151 | ; 152 | 153 | if (error) { 154 | content = <> 155 |

    ⚠ Something went wrong.

    156 | ; 157 | } 158 | 159 | 160 | return } 164 | menu={menu} 165 | onBeginOpening={async () => { 166 | try { 167 | await port.open({ baudRate: useImprovSerial.baudRate }); 168 | await improv.finger(); 169 | } catch(error) { 170 | console.error(error); 171 | setError(error); 172 | } 173 | }} 174 | onDoneClosing={async () => { 175 | if (port.opened) { 176 | await port.close(); 177 | } 178 | setError(null); 179 | }} 180 | > 181 | {content} 182 | ; 183 | } 184 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ESPHome Web App 2 | The ESPHome Web App (ESPWA) is a Progressive Web App (PWA) designed to streamline the setup, configuration, and control of ESPHome-based microcontrollers independently, without requiring additional infrastructure like an ESPHome or Home Assistant instance. 3 | 4 | Running directly in your browser, ESPHome Web App operates locally and can even function without an internet connection once cached. Its only requirement is an active ESPHome web_server component for communication with the ESPHome MCU. 5 | 6 | ESPHome Web App 7 | 8 | ## Why Use ESPHome Web App? What Problems Does It Solve? 9 | Common questions often asked are "What problem does this solve?" or "Can't I just use Home 10 | Assistant/ESPHome?". 11 | 12 | And while ESPHome and Home Assistant are indeed powerful tools for managing ESPHome-based microcontrollers, they usually necessitate additional infrastructure like a Raspberry Pi or Home Assistant color hardware. ESPHome Web App however does not. 13 | 14 | ESPHome Web App uniquely enables provisioning, configuration, and control of ESPHome based devices in locations where such infrastructure might not be readily available, such as caravans and trailers, storage units, company offices, the coffeeshop around the corner or your parents home. There are many settings in which deploying additional hardware to manage and control ESPHome based devices is either too complicated, unsafe or simply unpractical. ESPHome Web App fills this gap and gives users a viable alternative. 15 | 16 | In addition, many "Makers", people and bussinesses that are building and deploying bespoke hardware solutions on the basis of ESPs and ESPHome, have a need for user-friendly and easily customizable user interfaces for their products. ESPHome Web App allows those Makers to deploy a customized and branded version of ESPHome Web App specficically for their users and products, allowing their users to seamlessly setup and control their ESPHome based hardware. All without having to dedicate large budgets to custom app development. 17 | 18 | ## Features 19 | - Implements all 14 entity types supported by ESPHome web_server, including lights, fans, locks, covers and climate entities. 20 | - Fully local and private. Manages and stores all connection and device information locally 21 | in your browser and never sends any of that anywhere else. 22 | - Served via an encrypted connection improving safety and security and enabling 23 | many advanced features (requires a browser with Private Network Access support). 24 | - Provisioning of Wi-Fi and flashing of firmware binaries via a USB connection. 25 | - Touch-friendly user-interface that is easy to use on mobile devices. 26 | - Fully functional and available even while offline (once cached). 27 | - Installable as a standalone app. 28 | - Accessible via keyboard controls with additional accessibility features in the 29 | works. 30 | - Graceful fallback on browsers that do not support Private Network Access. 31 | 32 | 33 | \* *Some features are only available in the most recent versions of modern browsers with Private Network Access support.* 34 | 35 | ## Getting Started 36 | To begin using ESPHome Web App, flash an ESP32 or ESP8266 microcontroller with ESPHome, including the web_server component, and connect it to Wi-Fi. 37 | 38 | ```yaml 39 | web_server: 40 | ``` 41 | 42 | Then, access ESPHome Web App (an instance is hosted by me at https://esplink.app) and add your ESPHome-based device by clicking the "+" icon in the top-right corner and then the Wi-Fi icon. Enter the IP address or hostname into the input box and confirm the connection. 43 | 44 | *NOTE: If you are experincing problems connecting to your ESPHome MCU, try using 45 | the IP address instead of the hostname. ESPHomes mDNS hostname resolution can 46 | sometimes be causing intermittent problems.* 47 | 48 | ESPHome Web App will establish a connection with your ESPHome-based device, displaying all available entities and their respective states. The intuitive UI facilitates various actions, from toggling lights and switches to adjusting light color and color temperature, fan speeds, or cover positions and tilt. 49 | 50 | You can easily manage multiple devices within ESPHome Web App, as it 51 | conveniently stores a list of hosts for quick access upon your return. To change 52 | the order of stored devices, simply drag them around. 53 | 54 | ## Privacy and Security 55 | As a Progressive Web App, ESPHome Web App persists locally in your browser after the initial load, ensuring access even without an active internet connection. 56 | 57 | All data exchanged between ESPHome Web App and your ESPHome-based 58 | microcontrollers remains within your local network. The app connects directly to the microcontroller, with no data transmitted back to the servers hosting ESPHome Web App. 59 | 60 | Additionally, the list and addresses of connected devices are stored locally in your browser and are not shared with external parties. 61 | 62 | On browsers that support Private Network Access, ESPHome Web App uses an 63 | encrypted connection to the web server hosting the application. This ensures you 64 | privacy and safety while using ESPHome Web App and also enables many of the more 65 | advanced features, like offline access, USB provisioning and firmware flashing. 66 | 67 | ## Self-hosting and Branding 68 | While an instance of ESPHome Web App is hosted at https://esplink.app, the app is designed for easy self-hosting and branding. 69 | 70 | To do so, simply clone this code repository to your web server, run the build 71 | script with `npm run build` and point your document root to the generated `public/` 72 | output folder. 73 | 74 | You can customize your instance of ESPHome Web App by creating a esphome-web.json file in the project root. It can be used to override many defaults, like the color scheme, the title and much more. 75 | 76 | You can learn more about the customization and branding options including how to 77 | create fully custom setup- and workflows in the [Setup and Customize an ESPHome Web App instance](https://www.rarelyunplugged.com/posts/setup-and-customize-a-esphome-web-app-instance/) guide. 78 | 79 | Feel free to reach out to me with any questions or concerns. I'm always happy to help. In addition I can provide professional consulting and support up to and including fully hosted solutions at reasonable rates. 80 | -------------------------------------------------------------------------------- /src/ui/components/FirmwareFlasher.jsx: -------------------------------------------------------------------------------- 1 | import Drawer from '../Drawer'; 2 | import Spinner from '../Spinner'; 3 | import RadialProgress from '../RadialProgress'; 4 | 5 | import css from '../css'; 6 | 7 | import { flexFill } from '../utility.module.css'; 8 | 9 | import { useState, useEffect, useReducer, useRef } from 'react'; 10 | import iif from '../../iif'; 11 | import sleep from '../../sleep'; 12 | 13 | import {Transport, ESPLoader} from 'esptool-js'; 14 | 15 | function useEspTool(port) { 16 | const esptoolRef = useRef({}); 17 | 18 | const [state, dispatch] = useReducer((state, action) => { 19 | switch (action.type) { 20 | case 'file_reading_start': 21 | return { 22 | ...state, 23 | reading: true, 24 | }; 25 | case 'file_reading_complete': 26 | return { 27 | ...state, 28 | reading: false, 29 | }; 30 | case 'upload_start': 31 | return { 32 | ...state, 33 | progress: 0, 34 | uploading: true, 35 | }; 36 | case 'upload_progress': { 37 | const { progress } = action; 38 | return { 39 | ...state, 40 | progress, 41 | }; 42 | } 43 | case 'upload_complete': 44 | return { 45 | uploading: false, 46 | progress: 1, 47 | }; 48 | case 'upload_fail': { 49 | const { error } = action; 50 | return { 51 | uploading: false, 52 | error, 53 | }; 54 | } 55 | case 'flashing_start': { 56 | return { 57 | ...state, 58 | flashing: true, 59 | done: false, 60 | error: null, 61 | }; 62 | } 63 | case 'flashing_complete': { 64 | return { 65 | ...state, 66 | flashing: false, 67 | done: true, 68 | }; 69 | } 70 | } 71 | throw new Error(`Invalid action ${action.type}`); 72 | }, {}); 73 | 74 | async function readFile(file) { 75 | dispatch({ type: 'file_reading_start' }); 76 | return new Promise((resolve, reject) => { 77 | const reader = new FileReader(); 78 | 79 | function onError() { 80 | cleanup(); 81 | reject(); 82 | } 83 | function onLoad(event) { 84 | cleanup(); 85 | resolve(event.target.result); 86 | } 87 | function cleanup() { 88 | reader.removeEventListener('load', onLoad); 89 | reader.removeEventListener('error', onError); 90 | } 91 | 92 | reader.addEventListener('load', onLoad); 93 | reader.addEventListener('error', onError); 94 | 95 | reader.readAsBinaryString(file) 96 | }); 97 | } 98 | 99 | async function resetTransport(transport) { 100 | // ESP Web Tools Install Button does this. 101 | // Not entirely sure why, the commit adding this doens't speak 102 | // to why this needs to happen, but I've been running into 103 | // issues whith not being able to talk to Improv after flashing 104 | // an MCU. So I'll see if this helps. 105 | await transport.device.setSignals({ 106 | dataTerminalReady: false, 107 | requestToSend: true, 108 | }); 109 | await sleep(250); 110 | await transport.device.setSignals({ 111 | dataTerminalReady: false, 112 | requestToSend: false, 113 | }); 114 | await sleep(250); 115 | } 116 | 117 | return [ 118 | state, { 119 | async flash(file) { 120 | const transport = new Transport(port, false); 121 | 122 | try { 123 | dispatch({ type: 'flashing_start' }); 124 | const loaderOptions = { 125 | transport, 126 | baudrate: 115200, 127 | romBaudrate: 115200, 128 | }; 129 | 130 | const loader = new ESPLoader(loaderOptions); 131 | 132 | await loader.main(); 133 | await loader.flashId(); 134 | 135 | const data = await readFile(file); 136 | 137 | const flashOptions = { 138 | fileArray: [{data, address: 0}], 139 | flashSize: "keep", 140 | flashMode: "keep", 141 | flashFreq: "keep", 142 | eraseAll: false, 143 | compress: true, 144 | reportProgress: (index, written, total) => { 145 | dispatch({ type: 'upload_progress', progress: written/total}); 146 | }, 147 | }; 148 | dispatch({ type: 'upload_start' }); 149 | await loader.writeFlash(flashOptions); 150 | await resetTransport(transport); 151 | dispatch({ type: 'upload_complete' }); 152 | dispatch({ type: 'flashing_complete' }); 153 | } catch(error) { 154 | await resetTransport(transport); 155 | dispatch({ type: 'upload_fail', error }); 156 | console.error(error); 157 | } 158 | }, 159 | }, 160 | ]; 161 | } 162 | 163 | async function showFilePicker(accept) { 164 | return new Promise((resolve, reject) => { 165 | const el = document.createElement('input'); 166 | el.style = 'display: none;'; 167 | el.type = 'file'; 168 | el.accept = accept; 169 | document.body.appendChild(el); 170 | 171 | function cleanup() { 172 | el.removeEventListener('change', onChange); 173 | el.removeEventListener('cancel', cleanup); 174 | document.body.removeChild(el); 175 | } 176 | function onChange(event) { 177 | cleanup(); 178 | const file = event.target.files[0]; 179 | if (!file) { 180 | return resolve(null); 181 | } 182 | resolve(file); 183 | } 184 | 185 | function onCancel(event) { 186 | cleanup(); 187 | resolve(null); 188 | } 189 | 190 | el.addEventListener('change', onChange); 191 | el.addEventListener('cancel', onCancel); 192 | 193 | el.click(); 194 | }); 195 | } 196 | 197 | const fileExtensions = '.bin,.img,.hex,.elf'; 198 | 199 | export default function FirmwareFlasher({port, onFirmwareUpdateDone, label}) { 200 | const [{flashing, progress, uploading, error, done}, esptool] = useEspTool(port); 201 | 202 | return <> 203 | {iif(uploading, )} 204 | {iif(flashing && !uploading, , )} 205 | {iif(error,

    ⚠ Something went wrong.

    )} 206 | {iif(done,

    Installed.

    )} 207 | {iif(!flashing, )} 219 | ; 220 | } 221 | -------------------------------------------------------------------------------- /src/sw.js: -------------------------------------------------------------------------------- 1 | async function cacheResources(resources) { 2 | const cache = await caches.open('v1'); 3 | await cache.addAll(resources); 4 | } 5 | 6 | async function cacheResource(key, resource) { 7 | const cache = await caches.open('v1'); 8 | return cache.put(key, resource); 9 | } 10 | 11 | async function fetchFromCacheFirst(request) { 12 | const cachedResponse = await fetchFromCache(request); 13 | // Skip serving the cache response in DEV 14 | if (cachedResponse && !import.meta.env.DEV) { 15 | return cachedResponse; 16 | } 17 | 18 | return await fetchFromNetwork(request); 19 | } 20 | 21 | async function fetchFromNetworkFirstAndUpdateCache(request) { 22 | try { 23 | return await fetchFromNetworkAndUpdateCache(request); 24 | } catch(e) { 25 | return await fetchFromCache(request); 26 | } 27 | } 28 | 29 | async function fetchFromNetworkFirst(request) { 30 | try { 31 | return await fetchFromNetwork(request); 32 | } catch(e) { 33 | return await fetchFromCache(request); 34 | } 35 | } 36 | 37 | async function fetchFromNetwork(request) { 38 | return fetch(request); 39 | } 40 | 41 | async function fetchFromNetworkAndUpdateCache(request) { 42 | const response = await fetchFromNetwork(request); 43 | if (response.ok) { 44 | const cache = await caches.open('v1'); 45 | cache.put(request, response.clone()); 46 | return response; 47 | } 48 | return response; 49 | } 50 | 51 | async function fetchFromCache(request) { 52 | if (request.url.endsWith('/sw.js')) { 53 | // Never serve sw.js from cache to avoid locking ourselves 54 | // into a SW version 55 | return Promise.reject(); 56 | } 57 | return caches.match(request); 58 | } 59 | 60 | const pluginManifest = { 61 | url: '/manifest.json', 62 | buildResourceList: (json) => { 63 | const resources = Object.values(json).filter(v => v !== 'sw.js').map(v => `/${v}`); 64 | return resources; 65 | }, 66 | } 67 | 68 | const viteManifest = { 69 | url: '/vite-manifest.json', 70 | buildResourceList: (json) => { 71 | const resources = Object.values(json).filter(v => v !== 'sw.js').map(v => `/${v}`); 72 | }, 73 | } 74 | 75 | async function cacheManifestResources(resourceManifest) { 76 | const { isCacheCurrent, updateCache } = await fetchResourceManifest(resourceManifest); 77 | if (isCacheCurrent) { 78 | console.debug('No changes in resource manifest. Skipping update.'); 79 | return; 80 | } 81 | return await updateCache(); 82 | } 83 | 84 | function isResourceEqual(first, second) { 85 | const firstEtag = first?.headers?.get('Etag'); 86 | const secondEtag = second?.headers?.get('Etag'); 87 | 88 | return !!firstEtag && firstEtag === secondEtag; 89 | } 90 | 91 | async function fetchResourceManifest(resourceManifest) { 92 | if (import.meta.env.DEV) { 93 | // We don't have a resource manifest in DEV 94 | // See https://github.com/DanielBaulig/esphome-web-app/issues/2 95 | return { isCacheCurrent: true, updateCache: async () => {} }; 96 | } 97 | try { 98 | const [manifest, cachedManifest] = await Promise.all([ 99 | fetch(resourceManifest.url, {cache: 'no-cache'}), 100 | caches.match(resourceManifest.url), 101 | ]); 102 | const isCacheCurrent = isResourceEqual(manifest, cachedManifest); 103 | 104 | const updateCache = async () => { 105 | const json = await manifest.clone().json(); 106 | const resources = resourceManifest.buildResourceList(json); 107 | 108 | const [cache,] = await Promise.all([ 109 | caches.open('v1'), 110 | cacheResources(resources) 111 | ]); 112 | cache.put(resourceManifest.url, manifest); 113 | } 114 | 115 | return { isCacheCurrent: isCacheCurrent, updateCache: updateCache }; 116 | } catch(e) { 117 | // Network requests failed, we may be offline 118 | return { 119 | isCacheCurrent: true, 120 | updateCache: async () => {}, 121 | }; 122 | } 123 | } 124 | 125 | async function cacheAppResources() { 126 | console.debug('Updating app cache'); 127 | return Promise.all([cacheManifestResources(pluginManifest), cacheResources([ 128 | '/', 129 | '/icons/192.png', 130 | '/icons/256.png', 131 | ])]); 132 | } 133 | 134 | self.addEventListener("install", event => { 135 | event.waitUntil(cacheAppResources()); 136 | }); 137 | 138 | self.addEventListener('activate', event => { 139 | return self.clients.claim(); 140 | }); 141 | 142 | // See https://github.com/DanielBaulig/esphome-web-app/issues/1 143 | // self.addEventListener('sync', (event) => { 144 | // console.log('SW: sync event') 145 | // if (event.tag === 'update-app-cache') { 146 | // console.log('SW: update-app-cache'); 147 | // event.waitUntil(async () => { 148 | // const [updated,] = await cacheAppResources(); 149 | // if (!updated) { 150 | // console.log('SW: app-cache is up to date'); 151 | // return; 152 | // } 153 | // console.log('SW: app-cache was updated'); 154 | // const clients = await clients.matchAll(); 155 | // clients.forEach(client => client.postMessage({type: 'app-update'})); 156 | // }); 157 | // } 158 | // }); 159 | 160 | async function postMessage(clientId, message) { 161 | const client = await clients.get(clientId); 162 | client.postMessage(message); 163 | } 164 | 165 | self.addEventListener('fetch', (event) => { 166 | const request = event.request; 167 | 168 | // Avoid handling requests not going to origin 169 | if (!request.url.startsWith(location.origin)) { 170 | console.debug('Bypassing request to different origin', request); 171 | 172 | // If we see a PNA request in the ServiceWorker, then the request 173 | // was not blocked by the mixed content policy. 174 | // This holds true at least currently in Chrome, which is the only 175 | // browser implementing PNA confirmation dialogs anyway. 176 | if (request.targetAddressSpace === 'private') { 177 | postMessage(event.clientId, 'pna_confirm'); 178 | } 179 | 180 | return false; 181 | } 182 | 183 | if (request.mode === 'navigate') { 184 | console.log('Navigate request'); 185 | // We want to make sure we update the app resource cache if 186 | // we get a main resource navigation (i.e. we are loading the app).. 187 | const createNavigateResponse = async () => { 188 | const [response, {isCacheCurrent, updateCache}] = await Promise.all([ 189 | // Fetch the main resource from the network and update the cache with it 190 | // Fallback to loading from cache 191 | fetchFromNetworkFirstAndUpdateCache(request), 192 | // At the same time try loading the resource manifest 193 | // If the local cache is not up to date with the resource 194 | // manifest, we can re-cache the entire application before 195 | // continuing. 196 | fetchResourceManifest(pluginManifest) 197 | ]); 198 | 199 | if (!isCacheCurrent) { 200 | // Re-cache all resources if the resource manifest has changed 201 | // NOTE: Recaching the entire application may be slow on slower 202 | // connections, making the initial navigation to the app slow. 203 | // We can optimize this down the line. For now it's more important 204 | // that the app will work reliably even when later offline. 205 | await updateCache(); 206 | } 207 | 208 | return response; 209 | }; 210 | 211 | return event.respondWith(createNavigateResponse()); 212 | } 213 | 214 | // The entire application should be loaded in the cache and up to date. 215 | return event.respondWith(fetchFromCacheFirst(request)); 216 | }); 217 | -------------------------------------------------------------------------------- /src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import ControllerList from './ui/components/ControllerList.jsx'; 4 | import SerialConnectionList from './ui/components/SerialConnectionList.jsx'; 5 | import Header from './ui/Header.jsx'; 6 | import ControllerRegistry from './ControllerRegistry.js'; 7 | import Toast from './ui/Toast.jsx'; 8 | 9 | import { useState, useEffect, useRef } from 'react'; 10 | 11 | import { title, insecureOrigin, insecureOriginTemplate } from './config'; 12 | import { footer, dropTarget, dropIndicator, dropSpacer } from './main.module.css'; 13 | 14 | function privateAddressSpaceFetch(...args) { 15 | const isInsecureTarget = args[0].toString().startsWith('http:'); 16 | const usePrivateAddressSpace = globalThis.isSecureContext && isInsecureTarget; 17 | if (usePrivateAddressSpace) { 18 | if (args.length < 2) { 19 | args.push({}); 20 | } 21 | Object.assign(args[1], { 22 | targetAddressSpace: 'private', 23 | }); 24 | } 25 | 26 | return fetch(...args); 27 | } 28 | 29 | const registry = new ControllerRegistry('controllers', {fetch: privateAddressSpaceFetch}); 30 | 31 | import './main.css'; 32 | import 'virtual:custom.css'; 33 | 34 | function sw() { 35 | if (import.meta.env.DEV) { 36 | return 'src/sw.js'; 37 | } else { 38 | return '/sw.js'; 39 | } 40 | } 41 | 42 | if ('serviceWorker' in navigator) { 43 | navigator.serviceWorker.register(sw(), { scope: '/', type: 'module' }).then((registration) => { 44 | console.log(`Registration succesful ${registration}`); 45 | }); 46 | } else console.warn('ServiceWorker not supported'); 47 | 48 | function ltrim(str, ch) { 49 | // Make sure ch is a single character 50 | ch = ch.charAt(0); 51 | const rx = new RegExp(`^[${ch}]*(.*)$`); 52 | return str.match(rx)[1]; 53 | } 54 | 55 | async function waitForMessage(message, ms) { 56 | const p = new Promise((resolve, reject) => { 57 | let timeout; 58 | function cleanup() { 59 | clearTimeout(timeout); 60 | timeout = 0; 61 | navigator.serviceWorker.removeEventListener('message', handler); 62 | } 63 | function handler(event) { 64 | if (event.data === message) { 65 | if (ms !== null && !timeout) { 66 | return; 67 | } 68 | cleanup(); 69 | resolve(); 70 | } 71 | } 72 | if (ms !== null) { 73 | timeout = setTimeout(() => { 74 | cleanup(); 75 | reject(); 76 | }, ms); 77 | } 78 | navigator.serviceWorker.addEventListener('message', handler); 79 | }); 80 | return p; 81 | } 82 | 83 | let receivedPrivateNetworkAccessConfirmation = false; 84 | async function waitForPrivateNetworkAccessRequestConfirmation(ms = 1000) { 85 | if (receivedPrivateNetworkAccessConfirmation) { 86 | return true; 87 | } 88 | try { 89 | await waitForMessage('pna_confirm', ms); 90 | return receivedPrivateNetworkAccessConfirmation = true; 91 | } catch(e) { 92 | return receivedPrivateNetworkAccessConfirmation; 93 | } 94 | } 95 | 96 | // Let's montior for a PrivateNetworkAccess confirmation message 97 | waitForPrivateNetworkAccessRequestConfirmation(null); 98 | 99 | function App({controllerRegistry}) { 100 | const [mostRecentPort, setMostRecentPort] = useState(null); 101 | const [controllers, setControllers] = useState(getRegisteredControllers()); 102 | const [hasStrictMixedContent, setHasStrictMixedContent] = useState(undefined); 103 | const addHostDropTarget = useRef(null); 104 | const controllerList = useRef(null); 105 | const [isAcceptingDrop, setAcceptDrop] = useState(false); 106 | 107 | function getRegisteredControllers() { 108 | return controllerRegistry.hosts.map(host => registry.controllers[host]); 109 | } 110 | 111 | async function checkStrictMixedContent() { 112 | if (!globalThis.isSecureContext) { 113 | return; 114 | } 115 | if (hasStrictMixedContent === undefined) { 116 | setHasStrictMixedContent(!await waitForPrivateNetworkAccessRequestConfirmation(1000)); 117 | } 118 | } 119 | 120 | function interceptHashNavigation(event) { 121 | if (!event.hashChange) { 122 | return; 123 | } 124 | 125 | event.intercept({ handler() { 126 | executeHashQueryActions(event.destination.url); 127 | } }); 128 | } 129 | 130 | function clearHash() { 131 | const url = new URL(location.href); 132 | url.hash = ''; 133 | history.replaceState(null, '', url); 134 | } 135 | 136 | function executeAddHostHashAction(host) { 137 | if (!host) { 138 | return null; 139 | } 140 | 141 | if (controllerRegistry.has(host)) { 142 | return controllerRegistry.get(host).connect(); 143 | } 144 | 145 | if(!confirm(`Would you like to add the host ${host}?`)) { 146 | return; 147 | } 148 | 149 | addHost(host); 150 | } 151 | 152 | function executeHashQueryActions(str = location.href) { 153 | const url = new URL(str); 154 | const query = new URLSearchParams(ltrim(url.hash, '#')); 155 | 156 | for (const [k, v] of query) { 157 | if (k === 'addhost') { 158 | executeAddHostHashAction(v); 159 | } 160 | } 161 | 162 | clearHash(); 163 | } 164 | 165 | function insertHost(host, controller) { 166 | const connect = !controllerRegistry.has(host); 167 | controllerRegistry.insertHost(host, controller); 168 | if (connect) { 169 | controller.connect(); 170 | } 171 | setControllers(getRegisteredControllers()); 172 | } 173 | 174 | async function addHost(host) { 175 | const controller = controllerRegistry.addHost(host); 176 | controller.connect(); 177 | setControllers(getRegisteredControllers()); 178 | } 179 | 180 | function removeHost(host) { 181 | controllerRegistry.removeHost(host); 182 | setControllers(getRegisteredControllers()); 183 | } 184 | 185 | function promptAndAddHost() { 186 | const host = prompt('Host'); 187 | if (host === null) { 188 | return; 189 | } 190 | addHost(host); 191 | } 192 | 193 | useEffect(() => { 194 | function onControllerError(event) { 195 | checkStrictMixedContent(); 196 | } 197 | controllerRegistry.addEventListener('controllererror', onControllerError); 198 | 199 | return () => { 200 | controllerRegistry.removeEventListener('controllererror', onControllerError); 201 | }; 202 | }, [controllerRegistry]); 203 | 204 | useEffect(() => { 205 | executeHashQueryActions(); 206 | 207 | const navigation = globalThis.navigation; 208 | 209 | if(!navigation) { 210 | return; 211 | } 212 | 213 | navigation.addEventListener('navigate', interceptHashNavigation); 214 | 215 | return () => { 216 | navigation.removeEventListener('navigate', interceptHashNavigation); 217 | }; 218 | }, []); 219 | 220 | let href = insecureOrigin; 221 | if (!href) { 222 | const url = new URL(location.href); 223 | url.protocol = 'http'; 224 | href = url.href; 225 | } 226 | 227 | if (insecureOriginTemplate) { 228 | const url = new URL(location.href); 229 | const { hostname, port, protocol, pathname, search, hash } = url; 230 | 231 | const applyTemplate = (s, o) => { 232 | const keys = Object.keys(o); 233 | const values = Object.values(o); 234 | return new Function(...keys, `return \`${s}\`;`)(...values); 235 | }; 236 | 237 | href = applyTemplate(insecureOriginTemplate, { hostname, port, protocol, pathname, search, hash }); 238 | } 239 | 240 | 241 | const strictMixedContentWarning = 242 | This user agent appears to not allow access to private network hosts from secure origins. Please try loading the insecure origin instead. 243 | ; 244 | 245 | return ( 246 | <> 247 |
    promptAndAddHost()} 249 | onConnectSerialPort={(port) => setMostRecentPort(port)} 250 | /> 251 | {strictMixedContentWarning} 252 |
    253 | 256 |
    { 260 | if (isAcceptingDrop) { 261 | event.preventDefault(); 262 | } 263 | }} 264 | onDragEnter={(event) => { 265 | setAcceptDrop(true); 266 | }} 267 | onDragLeave={(event) => { 268 | const el = addHostDropTarget.current; 269 | const cl = controllerList.current; 270 | if (el.contains(event.relatedTarget) && !cl.contains(event.relatedTarget)) { 271 | return; 272 | } 273 | setAcceptDrop(false); 274 | }} 275 | onDrop={(event) => { 276 | const hostMimeType = 'application/x.espwa.host'; 277 | setAcceptDrop(false); 278 | const dt = event.dataTransfer; 279 | if (!dt.types.includes(hostMimeType)) { 280 | return; 281 | } 282 | const data = dt.getData(hostMimeType); 283 | insertHost(JSON.parse(data).host, null); 284 | }} 285 | > 286 | { 291 | if (confirm(`Are you sure you want to remove the host ${controller.host}?`)) { 292 | removeHost(controller.host) ; 293 | } 294 | }} 295 | /> 296 | { isAcceptingDrop &&
    } 297 |
    298 |
    299 |
    300 |
    301 | 305 | {title} is Open Source 306 | 307 |
    {__COMMIT_HASH__ ? `Commit ${__COMMIT_HASH__}` : ''}
    308 |
    309 | 310 | ); 311 | } 312 | 313 | function renderRoot() { 314 | reactRoot.render( 315 | 316 | ); 317 | } 318 | 319 | 320 | const reactRoot = ReactDOM.createRoot((root => 321 | document.body.replaceChildren(root) || root 322 | )(document.createElement('div'))); 323 | 324 | renderRoot(); 325 | -------------------------------------------------------------------------------- /src/ui/components/ControllerList.jsx: -------------------------------------------------------------------------------- 1 | import Spinner from '../Spinner'; 2 | import DrawerCard from './DrawerCard'; 3 | import StateEntity from './entities/StateEntity'; 4 | import Icon from '@mdi/react'; 5 | 6 | import { 7 | useRef, 8 | useEffect, 9 | useState, 10 | useReducer, 11 | lazy, 12 | Suspense, 13 | forwardRef, 14 | } from 'react'; 15 | 16 | import { 17 | mdiAlert, 18 | mdiToyBrickSearch, 19 | mdiCloseThick, 20 | mdiWifiArrowLeftRight, 21 | mdiWifi 22 | } from '@mdi/js'; 23 | 24 | import { flexFill, flex } from '../utility.module.css'; 25 | import css from '../css'; 26 | import createAddHostURL from '../../createAddHostURL'; 27 | 28 | import { 29 | controllerList, 30 | closeButton, 31 | messageIcon, 32 | dropIndicator as dropIndicatorClass, 33 | item as itemClass, 34 | dragging as draggingClass, 35 | } from './ControllerList.module.css'; 36 | 37 | import { filters } from '../../config'; 38 | 39 | function delay(ms) { 40 | return new Promise((resolve, reject) => { 41 | setTimeout(resolve, ms); 42 | }); 43 | } 44 | 45 | const LightComponent = lazy(() => import('./entities/LightComponent')); 46 | const BinarySensorEntity = lazy(() => import('./entities/BinarySensorEntity')); 47 | const ButtonEntity = lazy(() => import('./entities/ButtonEntity')); 48 | const SelectEntity = lazy(() => import('./entities/SelectEntity')); 49 | const SensorEntity = lazy(() => import('./entities/SensorEntity')); 50 | const TextSensorEntity = lazy(() => import('./entities/TextSensorEntity')); 51 | const SwitchEntity = lazy(() => import('./entities/SwitchEntity')); 52 | const FanEntity = lazy(() => import('./entities/FanEntity')); 53 | const CoverEntity = lazy(() => import('./entities/CoverEntity')); 54 | const NumberEntity = lazy(() => import('./entities/NumberEntity')); 55 | const TextEntity = lazy(() => import('./entities/TextEntity')); 56 | const LockEntity = lazy(() => import('./entities/LockEntity')); 57 | const ClimateEntity = lazy(() => import('./entities/ClimateEntity')); 58 | 59 | function getComponentForEntity(entity) { 60 | const loading = ; 61 | switch (entity.type) { 62 | case 'light': 63 | return 64 | 65 | ; 66 | case 'binary_sensor': 67 | return 68 | 69 | ; 70 | case 'button': 71 | return 72 | 73 | ; 74 | case 'select': 75 | return 76 | 77 | ; 78 | case 'sensor': 79 | return 80 | 81 | ; 82 | case 'text_sensor': 83 | return 84 | 85 | ; 86 | case 'switch': 87 | return 88 | 89 | ; 90 | case 'fan': 91 | return 92 | 93 | ; 94 | case 'cover': 95 | return 96 | 97 | ; 98 | case 'number': 99 | return 100 | 101 | ; 102 | case 'text': 103 | return 104 | 105 | ; 106 | case 'lock': 107 | return 108 | 109 | ; 110 | case 'climate': 111 | return 112 | 113 | ; 114 | default: 115 | return 116 | } 117 | 118 | return null; 119 | } 120 | 121 | function makeFilter(template) { 122 | if (typeof template !== 'object') { 123 | template = { type: 'id', value: template }; 124 | } 125 | 126 | switch (template.type) { 127 | case 'rx': 128 | const rx = RegExp(template.value); 129 | return (controller, entity) => rx.test(entity.id) 130 | case 'id': 131 | return (controller, entity) => entity.id === template.value; 132 | case 'type': 133 | return (controller, entity) => entity.type === template.value; 134 | case 'state': 135 | return (controller, entity) => controller.entities[template.entity]?.data?.state == template.value 136 | case 'and': 137 | const andFilters = template.value.map(makeFilter); 138 | return (controller, entity) => andFilters.every(filter => filter(controller, entity)) 139 | case 'or': 140 | const orFilters = template.value.map(makeFilter); 141 | return (controller, entity) => orFilters.some(filter => filter(controller, entity)) 142 | 143 | } 144 | 145 | throw new Error('Invalid filter'); 146 | } 147 | 148 | const initializedEntityFilters = filters.map(makeFilter); 149 | 150 | function filterEntities(controller) { 151 | return (entity) => { 152 | if (!initializedEntityFilters.length) { 153 | return true; 154 | } 155 | return initializedEntityFilters.some((filter) => filter(controller, entity)) 156 | }; 157 | } 158 | 159 | function ControllerEntities({entities}) { 160 | const components = entities.map(entity => getComponentForEntity(entity)).filter(c => !!c); 161 | 162 | return components; 163 | }; 164 | 165 | function pullControllerState(controller) { 166 | return { 167 | connected: controller.connected, 168 | connecting: controller.connecting, 169 | entities: Object.values(controller.entities).filter(filterEntities(controller)), 170 | }; 171 | } 172 | 173 | function useController(controller) { 174 | const activityTimeoutRef = useRef(null); 175 | 176 | const [state, dispatch] = useReducer((state, action) => { 177 | switch (action.type) { 178 | case 'disconnected': 179 | return { ...state, ...pullControllerState(controller), error: false }; 180 | case 'connecting': 181 | return { ...state, ...pullControllerState(controller) }; 182 | case 'connected': 183 | return { ...state, ...pullControllerState(controller) }; 184 | case 'activity_begin': 185 | return { ...state, activity: true, lastActivity: Date.now() }; 186 | case 'activity_end': 187 | return { ...state, activity: false }; 188 | case 'error': 189 | return { ...state, error: true }; 190 | case 'entitydiscovered': 191 | return { ...state, ...pullControllerState(controller) } 192 | // TODO: Right now this does not cover updates to entities themselves. 193 | // However, state filters might actually require a re-render when 194 | // entity state itself changes. 195 | } 196 | throw new Error(`Invalid action ${action.type}`); 197 | }, { ...pullControllerState(controller) }); 198 | 199 | useEffect(() => { 200 | const onConnected = () => { 201 | dispatch({type: 'connected'}); 202 | }; 203 | const onEntityDiscovered = (event) => { 204 | dispatch({type: 'entitydiscovered'}); 205 | }; 206 | const onError = (event) => { 207 | dispatch({type: 'error'}); 208 | }; 209 | const onActivity = (e) => { 210 | const activityTimeout = 500; 211 | 212 | if (activityTimeoutRef.current) { 213 | clearTimeout(activityTimeoutRef.current); 214 | } 215 | activityTimeoutRef.current = setTimeout(() => { 216 | activityTimeoutRef.current = null; 217 | dispatch({type: 'activity_end' }); 218 | }, activityTimeout); 219 | 220 | dispatch({type: 'activity_begin'}); 221 | }; 222 | 223 | function onConnecting(event) { 224 | dispatch({type: 'connecting'}); 225 | } 226 | 227 | controller.addEventListener('entitydiscovered', onEntityDiscovered); 228 | controller.addEventListener('log', onActivity); 229 | controller.addEventListener('state', onActivity); 230 | controller.addEventListener('ping', onActivity); 231 | controller.addEventListener('connected', onConnected); 232 | controller.addEventListener('error', onError); 233 | controller.addEventListener('connecting', onConnecting); 234 | return () => { 235 | controller.removeEventListener('connected', onConnected); 236 | controller.removeEventListener('entitydiscovered', onEntityDiscovered); 237 | controller.removeEventListener('log', onActivity); 238 | controller.removeEventListener('state', onActivity); 239 | controller.removeEventListener('ping', onActivity); 240 | controller.removeEventListener('error', onError); 241 | controller.removeEventListener('connecting', onConnecting); 242 | }; 243 | }, [controller]); 244 | 245 | const actions = { 246 | connect() { 247 | controller.connect(); 248 | }, 249 | disconnect() { 250 | controller.disconnect(); 251 | dispatch({ type: 'disconnected' }); 252 | }, 253 | toggle() { 254 | if (state.connected || state.connecting) { 255 | actions.disconnect(); 256 | } else { 257 | actions.connect(); 258 | } 259 | }, 260 | }; 261 | 262 | return [state, actions]; 263 | } 264 | 265 | function ControllerListItem({controller, onRemove, onDrop}) { 266 | const [state, actions] = useController(controller); 267 | const isConnected = state.connecting || state.connected; 268 | const [isDrawerOpen, setDrawerOpen] = useState(isConnected); 269 | const [isDragAccept, setDragAccept] = useState(false); 270 | const [dragging, setDragging] = useState(false); 271 | const liRef = useRef(null); 272 | const dragEnterRef = useRef(null); 273 | 274 | let cardContent = ; 275 | if (state.connected && state.lastActivity) { 276 | if (state.entities.length > 0) { 277 | cardContent = ; 278 | } else { 279 | cardContent = <> 280 | 281 |

    No entities found

    282 | ; 283 | } 284 | } 285 | 286 | if (state.error) { 287 | cardContent = <> 288 | 289 |

    Something went wrong

    290 | ; 291 | } 292 | 293 | useEffect(() => { 294 | if (isConnected) { 295 | setDrawerOpen(true); 296 | } 297 | }, [isConnected]); 298 | 299 | const activityIcon = state.activity ? mdiWifiArrowLeftRight : mdiWifi; 300 | const lastActivity = state.lastActivity ? 301 | `Last activity at ${(new Date(state.lastActivity)).toLocaleString()}` : 302 | 'No activity yet'; 303 | const glyph = ; 308 | 309 | const hostMimeType = 'application/x.espwa.host'; 310 | const dropIndicator = isDragAccept ?
    : null; 311 | 312 | const classNames = css({ 313 | [itemClass]: true, 314 | [draggingClass]: !!dragging, 315 | }); 316 | 317 | return ( 318 |
  • { 322 | if (isDragAccept) { 323 | event.preventDefault(); 324 | } 325 | }} 326 | onDragEnter={(e) => { 327 | dragEnterRef.current = e.target; 328 | e.stopPropagation(); 329 | const dt = e.dataTransfer; 330 | if (!dt.types.includes(hostMimeType)) { 331 | // Only accept x.espwa.host drops 332 | return; 333 | } 334 | if (dragging) { 335 | // Don't accept itself 336 | return; 337 | } 338 | e.preventDefault(); 339 | setDragAccept(true) 340 | }} 341 | onDragLeave={(e) => { 342 | e.stopPropagation(); 343 | // lastDragEnter is a hack to account for this Safari bug: 344 | // https://bugs.webkit.org/show_bug.cgi?id=66547 345 | // Basically Safari doesn't provide a relatedTarget element on 346 | // dragleave events. 347 | // Here's how we work around it: 348 | // The order of events for dragenter and dragleave is as follows: 349 | // User moves cursor over parent element 350 | // - Parent emits dragenter 351 | // User moves cursor over child element 352 | // - Child emits dragenter 353 | // - Parent dragleave 354 | // User moves cursor off of child element 355 | // - Parent emits dragenter 356 | // - Child emits dragleave 357 | // This means that the dragenter event for the new element fires 358 | // before the dragleave event for the old element fires. By caching 359 | // the most recent dragenter element, we can use the cached 360 | // dragenter target as a standin for dragleave.relatedTarget 361 | const lastDragEnter = dragEnterRef.current; 362 | dragEnterRef.current = null; 363 | if ( 364 | e.relatedTarget === liRef.current || 365 | liRef.current.contains(e.relatedTarget || lastDragEnter) 366 | ) { 367 | return; 368 | } 369 | e.preventDefault(); 370 | setDragAccept(false); 371 | }} 372 | onDrop={(event) => { 373 | event.stopPropagation(); 374 | setDragAccept(false); 375 | const dt = event.dataTransfer; 376 | if (!dt.types.includes(hostMimeType)) { 377 | return; 378 | } 379 | const data = dt.getData(hostMimeType); 380 | onDrop(JSON.parse(data).host); 381 | }} 382 | > 383 | {dropIndicator} 384 | setDrawerOpen(!isDrawerOpen)} 388 | onBeginOpening={() => actions.connect()} 389 | onDoneClosing={() => actions.disconnect()} 390 | onDragEnd={() => { 391 | setDragging(false); 392 | }} 393 | onDragStart={(e) => { 394 | setDragging(true); 395 | const dt = e.dataTransfer; 396 | const host = controller.host; 397 | const uri = createAddHostURL(host); 398 | dt.setData(hostMimeType, JSON.stringify({ host })); 399 | dt.setData('text/uri-list', uri); 400 | dt.setData('text/plain', uri); 401 | e.dataTransfer.dropEffect = 'move'; 402 | }} 403 | glyph={glyph} 404 | menu={ 405 | 408 | } 409 | > 410 | {cardContent} 411 | 412 |
  • 413 | ); 414 | } 415 | 416 | export default forwardRef(function ControllerList({controllers, onRemoveController, onInsertHost }, ref) { 417 | let previousController = null; 418 | return
      419 | {controllers.map(controller => { 420 | return onRemoveController(controller)} 424 | onDrop={(host) => onInsertHost(host, controller)} 425 | />; 426 | })} 427 |
    ; 428 | }); 429 | --------------------------------------------------------------------------------