diff --git a/README.md b/README.md index 8fafc5364..77d981e27 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Visit [emsesp.org](https://docs.emsesp.org) for more details on how to install a To chat with the community reach out on our [Discord Server](https://discord.gg/3J3GgnzpyT). -If you find an issue or have a request, see [here](https://docs.emsesp.org/Support/) on how to submit a bug report or feature request. +If you find an issue or have a request, see [how to request support](https://docs.emsesp.org/Support/) on how to submit a bug report or feature request. ## 🎥  **Live Demo** @@ -82,13 +82,19 @@ EMS-ESP is a project created by [proddy](https://github.com/proddy) and owned an If you like **EMS-ESP**, please give it a ✨ on GitHub, or even better fork it and contribute. You can also offer a small donation. This is an open-source project maintained by volunteers, and your support is greatly appreciated. +## 📦  **Building** + +To build the web interface only, run `platformio run -e build_webUI`. This will install the necessary dependencies and build the web interface and also create the embedded code used need to build the firmware. You can run the web interface locally by going to the `interface` directory and running `pnpm standalone`. + +To build the firmware, run `platformio run`. This will build the firmware for all ESP32 modules and place the binaries in the `build/firmware` folder. If you want to configure the build for a single platform create a local `pio_local.ni` file in the root directory (see example in `pio_local.ini_example`). + ## 📢  **Libraries used** -- [esp8266-react](https://github.com/rjwats/esp8266-react) by @rjwats for the core framework that provides the Web UI, which has been heavily modified +- [esp8266-react](https://github.com/rjwats/esp8266-react) originally by @rjwats for the core framework that provides the Web UI, which has been heavily modified - [uuid-\*](https://github.com/nomis/mcu-uuid-console) from @nomis. The console, syslog, telnet and logging are based off these awesome open source libraries - [ArduinoJson](https://github.com/bblanchon/ArduinoJson) for all the JSON processing - [espMqttClient](https://github.com/bertmelis/espMqttClient) for the MQTT client -- [ESPAsyncWebServer](https://github.com/ESP32Async/ESPAsyncWebServer) and [AsyncTCP](https://github.com/ESP32Async/AsyncTCP) for the Web server and TCP backends +- [ESPAsyncWebServer](https://github.com/ESP32Async/ESPAsyncWebServer) and [AsyncTCP](https://github.com/ESP32Async/AsyncTCP) for the Web server ## 📜  **License** diff --git a/interface/pnpm-lock.yaml b/interface/pnpm-lock.yaml index 8a510778f..bc79ff892 100644 --- a/interface/pnpm-lock.yaml +++ b/interface/pnpm-lock.yaml @@ -635,8 +635,8 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@paralleldrive/cuid2@2.2.2': - resolution: {integrity: sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==} + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} @@ -3565,7 +3565,7 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 - '@paralleldrive/cuid2@2.2.2': + '@paralleldrive/cuid2@2.3.1': dependencies: '@noble/hashes': 1.8.0 @@ -4714,7 +4714,7 @@ snapshots: formidable@3.5.4: dependencies: - '@paralleldrive/cuid2': 2.2.2 + '@paralleldrive/cuid2': 2.3.1 dezalgo: 1.0.4 once: 1.4.0 diff --git a/interface/src/App.tsx b/interface/src/App.tsx index 7b4115c2c..496f2e3eb 100644 --- a/interface/src/App.tsx +++ b/interface/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { ToastContainer, Zoom } from 'react-toastify'; import AppRouting from 'AppRouting'; @@ -8,7 +8,8 @@ import type { Locales } from 'i18n/i18n-types'; import { loadLocaleAsync } from 'i18n/i18n-util.async'; import { detectLocale, navigatorDetector } from 'typesafe-i18n/detectors'; -const availableLocales = [ +// Memoize available locales to prevent recreation on every render +const AVAILABLE_LOCALES = [ 'de', 'en', 'it', @@ -20,47 +21,59 @@ const availableLocales = [ 'sv', 'tr', 'cz' -]; +] as Locales[]; -const App = () => { +const App = memo(() => { const [wasLoaded, setWasLoaded] = useState(false); const [locale, setLocale] = useState('en'); - useEffect(() => { - // determine locale, take from session if set other default to browser language - const browserLocale = detectLocale('en', availableLocales, navigatorDetector); + // Memoize locale initialization to prevent unnecessary re-runs + const initializeLocale = useCallback(async () => { + const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector); const newLocale = (localStorage.getItem('lang') || browserLocale) as Locales; localStorage.setItem('lang', newLocale); setLocale(newLocale); - void loadLocaleAsync(newLocale).then(() => setWasLoaded(true)); + await loadLocaleAsync(newLocale); + setWasLoaded(true); }, []); + useEffect(() => { + void initializeLocale(); + }, [initializeLocale]); + + // Memoize toast container props to prevent recreation + const toastContainerProps = useMemo( + () => ({ + position: 'bottom-left' as const, + autoClose: 3000, + hideProgressBar: false, + newestOnTop: false, + closeOnClick: true, + rtl: false, + pauseOnFocusLoss: true, + draggable: false, + pauseOnHover: false, + transition: Zoom, + closeButton: false, + theme: 'dark' as const, + toastStyle: { + border: '1px solid #177ac9', + width: 'fit-content' + } + }), + [] + ); + if (!wasLoaded) return null; return ( - + ); -}; +}); export default App; diff --git a/interface/src/CustomTheme.tsx b/interface/src/CustomTheme.tsx index 579a8d597..850a95119 100644 --- a/interface/src/CustomTheme.tsx +++ b/interface/src/CustomTheme.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react'; import type { FC } from 'react'; import { CssBaseline, ThemeProvider, responsiveFontSizes } from '@mui/material'; @@ -5,6 +6,7 @@ import { createTheme } from '@mui/material/styles'; import type { RequiredChildrenProps } from 'utils'; +// Memoize dialog style to prevent recreation export const dialogStyle = { '& .MuiDialog-paper': { borderRadius: '8px', @@ -12,8 +14,9 @@ export const dialogStyle = { borderStyle: 'solid', borderWidth: '1px' } -}; +} as const; +// Memoize theme creation to prevent recreation const theme = responsiveFontSizes( createTheme({ typography: { @@ -30,15 +33,27 @@ const theme = responsiveFontSizes( text: { disabled: '#eee' // white } + }, + components: { + MuiListItemText: { + styleOverrides: { + primary: { + fontSize: 14 + }, + secondary: { + color: '#9e9e9e' // grey[500] + } + } + } } }) ); -const CustomTheme: FC = ({ children }) => ( +const CustomTheme: FC = memo(({ children }) => ( {children} -); +)); export default CustomTheme; diff --git a/interface/src/app/main/Dashboard.tsx b/interface/src/app/main/Dashboard.tsx index 1cb5aa9dd..fa318bbac 100644 --- a/interface/src/app/main/Dashboard.tsx +++ b/interface/src/app/main/Dashboard.tsx @@ -206,7 +206,7 @@ const Dashboard = memo(() => { // if its a device (parent node) and has entities if (di.nodes?.length) { return ( - +   {showType(di.n, di.t)}  ({di.nodes?.length}) @@ -312,7 +312,7 @@ const Dashboard = memo(() => { flexDirection="column" sx={{ borderRadius: 1, - border: '1px solid grey' + border: '1px solid rgb(65, 65, 65)' }} > { +const Devices = memo(() => { const { LL } = useI18nContext(); const { me } = useContext(AuthenticatedContext); @@ -141,11 +143,13 @@ const Devices = () => { return left + (right - left < 400 ? 0 : 200); }; - const common_theme = useTheme({ - BaseRow: ` + const common_theme = useMemo( + () => + useTheme({ + BaseRow: ` font-size: 14px; `, - HeaderRow: ` + HeaderRow: ` text-transform: uppercase; background-color: black; color: #90CAF9; @@ -153,7 +157,7 @@ const Devices = () => { border-bottom: 1px solid #565656; } `, - Row: ` + Row: ` cursor: pointer; background-color: #1E1E1E; .td { @@ -163,30 +167,47 @@ const Devices = () => { background-color: #177ac9; } ` - }); + }), + [] + ); - const device_theme = useTheme([ - common_theme, - { - Table: ` + const device_theme = useMemo( + () => + useTheme([ + common_theme, + { + BaseRow: ` + font-size: 15px; + .td { + height: 28px; + } + `, + Table: ` --data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px; `, - HeaderRow: ` + HeaderRow: ` .th { padding: 8px; `, - Row: ` - font-weight: bold; + Row: ` + &:nth-of-type(odd) .td { + background-color: #303030; + }, &:hover .td { background-color: #177ac9; + }, ` - } - ]); + } + ]), + [common_theme] + ); - const data_theme = useTheme([ - common_theme, - { - Table: ` + const data_theme = useMemo( + () => + useTheme([ + common_theme, + { + Table: ` --data-table-library_grid-template-columns: minmax(200px, auto) minmax(150px, auto) 40px; height: auto; max-height: 100%; @@ -195,12 +216,12 @@ const Devices = () => { display:none; } `, - BaseRow: ` + BaseRow: ` .td { height: 32px; } `, - BaseCell: ` + BaseCell: ` &:nth-of-type(1) { border-left: 1px solid #177ac9; }, @@ -211,12 +232,12 @@ const Devices = () => { border-right: 1px solid #177ac9; } `, - HeaderRow: ` + HeaderRow: ` .th { border-top: 1px solid #565656; } `, - Row: ` + Row: ` &:nth-of-type(odd) .td { background-color: #303030; }, @@ -224,8 +245,10 @@ const Devices = () => { background-color: #177ac9; } ` - } - ]); + } + ]), + [common_theme] + ); const getSortIcon = (state: State, sortKey: unknown) => { if (state.sortKey === sortKey && state.reverse) { @@ -324,8 +347,10 @@ const Devices = () => { return sc; }; - const hasMask = (id: string, mask: number) => - (parseInt(id.slice(0, 2), 16) & mask) === mask; + const hasMask = useCallback( + (id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask, + [] + ); const handleDownloadCsv = () => { const deviceIndex = coreData.devices.findIndex( @@ -510,55 +535,65 @@ const Devices = () => { const renderCoreData = () => ( <> - - {!coreData.connected && ( - - )} + + {!coreData.connected && ( + + )} - {coreData.connected && ( - - {(tableList: Device[]) => ( - <> -
- - {LL.DESCRIPTION()} - {LL.TYPE(0)} - -
- - {tableList.length === 0 && ( - - )} - {tableList.map((device: Device) => ( - - - -    - {device.n} - -   ({device.e}) - - - {device.tn} - - ))} - - - )} -
- )} -
+ {coreData.connected && ( + + {(tableList: Device[]) => ( + <> +
+ + {LL.DESCRIPTION()} + {LL.TYPE(0)} + +
+ + {tableList.length === 0 && ( + + )} + {tableList.map((device: Device) => ( + + + +    + {device.n} + +   ({device.e}) + + + {device.tn} + + ))} + + + )} +
+ )} +
+ ); @@ -574,35 +609,41 @@ const Devices = () => { return; } - const showDeviceValue = (dv: DeviceValue) => { + const showDeviceValue = useCallback((dv: DeviceValue) => { setSelectedDeviceValue(dv); setDeviceValueDialogOpen(true); - }; + }, []); - const renderNameCell = (dv: DeviceValue) => ( - <> - {dv.id.slice(2)}  - {hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && ( - - )} - {hasMask(dv.id, DeviceEntityMask.DV_READONLY) && ( - - )} - {hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && ( - - )} - + const renderNameCell = useCallback( + (dv: DeviceValue) => ( + <> + {dv.id.slice(2)}  + {hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && ( + + )} + {hasMask(dv.id, DeviceEntityMask.DV_READONLY) && ( + + )} + {hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && ( + + )} + + ), + [hasMask] ); - const shown_data = onlyFav - ? deviceData.nodes.filter( + const shown_data = useMemo(() => { + if (onlyFav) { + return deviceData.nodes.filter( (dv: DeviceValue) => hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && dv.id.slice(2).toLowerCase().includes(search.toLowerCase()) - ) - : deviceData.nodes.filter((dv: DeviceValue) => - dv.id.slice(2).toLowerCase().includes(search.toLowerCase()) ); + } + return deviceData.nodes.filter((dv: DeviceValue) => + dv.id.slice(2).toLowerCase().includes(search.toLowerCase()) + ); + }, [deviceData.nodes, onlyFav, search]); const deviceIndex = coreData.devices.findIndex( (d: Device) => d.id === device_select.state.id @@ -795,6 +836,6 @@ const Devices = () => { )} ); -}; +}); export default Devices; diff --git a/interface/src/app/status/Version.tsx b/interface/src/app/status/Version.tsx index 87dd1c2f4..584ac975b 100644 --- a/interface/src/app/status/Version.tsx +++ b/interface/src/app/status/Version.tsx @@ -19,9 +19,10 @@ import { Grid, IconButton, Link, - List, - ListItem, - ListItemText, + Table, + TableBody, + TableCell, + TableRow, Typography } from '@mui/material'; @@ -41,6 +42,7 @@ import { } from 'components'; import { AuthenticatedContext } from 'contexts/authentication'; import { useI18nContext } from 'i18n/i18n-react'; +import type { TranslationFunctions } from 'i18n/i18n-types'; import { prettyDateTime } from 'utils/time'; // Constants moved outside component to avoid recreation @@ -86,7 +88,7 @@ const VersionInfoDialog = memo( latestVersion?: VersionInfo; latestDevVersion?: VersionInfo; locale: string; - LL: any; + LL: TranslationFunctions; onClose: () => void; }) => { if (showVersionInfo === 0) return null; @@ -97,30 +99,69 @@ const VersionInfoDialog = memo( return ( - Version Information + {LL.FIRMWARE_VERSION_INFO()} - - - {LL.TYPE(0)}
} - secondary={isStable ? LL.STABLE() : LL.DEVELOPMENT()} - /> - - - {LL.VERSION()}
} - secondary={version?.name} - /> - - {version?.published_at && ( - - Release Date} - secondary={prettyDateTime(locale, new Date(version.published_at))} - /> - - )} - + + + + + {LL.TYPE(0)} + + + {isStable ? LL.STABLE() : LL.DEVELOPMENT()} + + + + + {LL.VERSION()} + + + {version?.name} + + + {version?.published_at && ( + + + Build Date + + + {prettyDateTime(locale, new Date(version.published_at))} + + + )} + +