├── 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
;
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 ;
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 ;
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 ;
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 |
42 | )}
43 | >;
44 | }
45 |
46 | return ;
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 ;
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 | `${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 |
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 |