diff --git a/.github/workflows/pr_check.yml b/.github/workflows/pr_check.yml
index cfde3c9cb..f7892bb1d 100644
--- a/.github/workflows/pr_check.yml
+++ b/.github/workflows/pr_check.yml
@@ -32,6 +32,6 @@ jobs:
pip install wheel
pip install -U platformio
- - name: Build native
+ - name: Build standalone
run: |
- platformio run -e native
+ platformio run -e standalone
diff --git a/interface/package.json b/interface/package.json
index db42b48ed..ae0c1770d 100644
--- a/interface/package.json
+++ b/interface/package.json
@@ -32,7 +32,7 @@
"async-validator": "^4.2.5",
"formidable": "^3.5.4",
"jwt-decode": "^4.0.0",
- "magic-string": "^0.30.19",
+ "magic-string": "^0.30.21",
"mime-types": "^3.0.1",
"preact": "^10.27.2",
"react": "^19.2.0",
diff --git a/interface/pnpm-lock.yaml b/interface/pnpm-lock.yaml
index 52f15e6f1..8a510778f 100644
--- a/interface/pnpm-lock.yaml
+++ b/interface/pnpm-lock.yaml
@@ -39,8 +39,8 @@ importers:
specifier: ^4.0.0
version: 4.0.0
magic-string:
- specifier: ^0.30.19
- version: 0.30.19
+ specifier: ^0.30.21
+ version: 0.30.21
mime-types:
specifier: ^3.0.1
version: 3.0.1
@@ -663,8 +663,8 @@ packages:
'@prefresh/utils@1.2.1':
resolution: {integrity: sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==}
- '@prefresh/vite@2.4.10':
- resolution: {integrity: sha512-lt+ODASOtXRWaPplp7/DlrgAaInnQYNvcpCglQBMx2OeJPyZ4IqPRaxsK77w96mWshjYwkqTsRSHoAM7aAn0ow==}
+ '@prefresh/vite@2.4.11':
+ resolution: {integrity: sha512-/XjURQqdRiCG3NpMmWqE9kJwrg9IchIOWHzulCfqg2sRe/8oQ1g5De7xrk9lbqPIQLn7ntBkKdqWXIj4E9YXyg==}
peerDependencies:
preact: ^10.4.0 || ^11.0.0-0
vite: '>=2.0.0'
@@ -1324,8 +1324,8 @@ packages:
duplexer3@0.1.5:
resolution: {integrity: sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==}
- electron-to-chromium@1.5.239:
- resolution: {integrity: sha512-1y5w0Zsq39MSPmEjHjbizvhYoTaulVtivpxkp5q5kaPmQtsK6/2nvAzGRxNMS9DoYySp9PkW0MAQDwU1m764mg==}
+ electron-to-chromium@1.5.240:
+ resolution: {integrity: sha512-OBwbZjWgrCOH+g6uJsA2/7Twpas2OlepS9uvByJjR2datRDuKGYeD+nP8lBBks2qnB7bGJNHDUx7c/YLaT3QMQ==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@@ -2146,8 +2146,8 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
- magic-string@0.30.19:
- resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==}
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
make-dir@1.3.0:
resolution: {integrity: sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==}
@@ -3580,7 +3580,7 @@ snapshots:
'@babel/core': 7.28.5
'@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.28.5)
'@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.28.5)
- '@prefresh/vite': 2.4.10(preact@10.27.2)(vite@7.1.12(@types/node@24.9.1)(terser@5.44.0))
+ '@prefresh/vite': 2.4.11(preact@10.27.2)(vite@7.1.12(@types/node@24.9.1)(terser@5.44.0))
'@rollup/pluginutils': 4.2.1
babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.28.5)
debug: 4.4.3
@@ -3599,7 +3599,7 @@ snapshots:
'@prefresh/utils@1.2.1': {}
- '@prefresh/vite@2.4.10(preact@10.27.2)(vite@7.1.12(@types/node@24.9.1)(terser@5.44.0))':
+ '@prefresh/vite@2.4.11(preact@10.27.2)(vite@7.1.12(@types/node@24.9.1)(terser@5.44.0))':
dependencies:
'@babel/core': 7.28.5
'@prefresh/babel-plugin': 0.5.2
@@ -3995,7 +3995,7 @@ snapshots:
dependencies:
baseline-browser-mapping: 2.8.20
caniuse-lite: 1.0.30001751
- electron-to-chromium: 1.5.239
+ electron-to-chromium: 1.5.240
node-releases: 2.0.26
update-browserslist-db: 1.1.4(browserslist@4.27.0)
@@ -4340,7 +4340,7 @@ snapshots:
duplexer3@0.1.5: {}
- electron-to-chromium@1.5.239: {}
+ electron-to-chromium@1.5.240: {}
emoji-regex@8.0.0: {}
@@ -5178,7 +5178,7 @@ snapshots:
dependencies:
yallist: 3.1.1
- magic-string@0.30.19:
+ magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -5967,7 +5967,7 @@ snapshots:
vite-prerender-plugin@0.5.12(vite@7.1.12(@types/node@24.9.1)(terser@5.44.0)):
dependencies:
kolorist: 1.8.0
- magic-string: 0.30.19
+ magic-string: 0.30.21
node-html-parser: 6.1.13
simple-code-frame: 1.3.0
source-map: 0.7.6
diff --git a/interface/src/app/main/Dashboard.tsx b/interface/src/app/main/Dashboard.tsx
index 8198a86fd..1cb5aa9dd 100644
--- a/interface/src/app/main/Dashboard.tsx
+++ b/interface/src/app/main/Dashboard.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
+import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { IconContext } from 'react-icons/lib';
import { Link } from 'react-router';
import { toast } from 'react-toastify';
@@ -44,7 +44,7 @@ import {
} from './types';
import { deviceValueItemValidation } from './validators';
-const Dashboard = () => {
+const Dashboard = memo(() => {
const { LL } = useI18nContext();
const { me } = useContext(AuthenticatedContext);
@@ -163,9 +163,14 @@ const Dashboard = () => {
}
});
+ const nodeIds = useMemo(
+ () => data.nodes.map((item: DashboardItem) => item.id),
+ [data.nodes]
+ );
+
useEffect(() => {
showAll
- ? tree.fns.onAddAll(data.nodes.map((item: DashboardItem) => item.id)) // expand tree
+ ? tree.fns.onAddAll(nodeIds) // expand tree
: tree.fns.onRemoveAll(); // collapse tree
}, [parentNodes]);
@@ -195,27 +200,32 @@ const Dashboard = () => {
[LL]
);
- const showName = (di: DashboardItem) => {
- if (di.id < 100) {
- // if its a device (parent node) and has entities
- if (di.nodes?.length) {
- return (
-
-
- {showType(di.n, di.t)}
- ({di.nodes?.length})
-
- );
+ const showName = useCallback(
+ (di: DashboardItem) => {
+ if (di.id < 100) {
+ // if its a device (parent node) and has entities
+ if (di.nodes?.length) {
+ return (
+
+
+ {showType(di.n, di.t)}
+ ({di.nodes?.length})
+
+ );
+ }
}
- }
- if (di.dv) {
- return {di.dv.id.slice(2)};
- }
- return null;
- };
+ if (di.dv) {
+ return {di.dv.id.slice(2)};
+ }
+ return null;
+ },
+ [showType]
+ );
- 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 editDashboardValue = useCallback(
(di: DashboardItem) => {
@@ -237,6 +247,11 @@ const Dashboard = () => {
}
};
+ const hasFavEntities = useMemo(
+ () => data.nodes.filter((item: DashboardItem) => item.id <= 90).length,
+ [data.nodes]
+ );
+
const renderContent = () => {
if (!data) {
return (
@@ -244,10 +259,6 @@ const Dashboard = () => {
);
}
- const hasFavEntities = data.nodes.filter(
- (item: DashboardItem) => item.id <= 90
- ).length;
-
return (
<>
{!data.connected && (
@@ -391,6 +402,6 @@ const Dashboard = () => {
)}
);
-};
+});
export default Dashboard;
diff --git a/interface/src/app/status/Version.tsx b/interface/src/app/status/Version.tsx
index 0178cac27..87dd1c2f4 100644
--- a/interface/src/app/status/Version.tsx
+++ b/interface/src/app/status/Version.tsx
@@ -1,10 +1,11 @@
-import { useContext, useEffect, useState } from 'react';
+import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import CloseIcon from '@mui/icons-material/Close';
import CheckIcon from '@mui/icons-material/Done';
import DownloadIcon from '@mui/icons-material/GetApp';
+import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
@@ -16,7 +17,11 @@ import {
DialogTitle,
FormControlLabel,
Grid,
+ IconButton,
Link,
+ List,
+ ListItem,
+ ListItemText,
Typography
} from '@mui/material';
@@ -36,168 +41,143 @@ import {
} from 'components';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
+import { prettyDateTime } from 'utils/time';
-const Version = () => {
- const { LL, locale } = useI18nContext();
- const { me } = useContext(AuthenticatedContext);
+// Constants moved outside component to avoid recreation
+const STABLE_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/';
+const STABLE_RELNOTES_URL =
+ 'https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md';
+const DEV_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/latest/';
+const DEV_RELNOTES_URL =
+ 'https://github.com/emsesp/EMS-ESP32/blob/dev/CHANGELOG_LATEST.md';
- const [restarting, setRestarting] = useState(false);
- const [openInstallDialog, setOpenInstallDialog] = useState(false);
- const [usingDevVersion, setUsingDevVersion] = useState(false);
- const [fetchDevVersion, setFetchDevVersion] = useState(false);
- const [devUpgradeAvailable, setDevUpgradeAvailable] = useState(false);
- const [stableUpgradeAvailable, setStableUpgradeAvailable] =
- useState(false);
- const [internetLive, setInternetLive] = useState(false);
- const [downloadOnly, setDownloadOnly] = useState(false);
+// Types for better type safety
+interface VersionData {
+ emsesp_version: string;
+ arduino_version: string;
+ esp_platform: string;
+ flash_chip_size: number;
+ psram: boolean;
+ build_flags?: string;
+}
- const STABLE_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/';
- const STABLE_RELNOTES_URL =
- 'https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md';
+interface UpgradeCheckData {
+ emsesp_version: string;
+ dev_upgradeable: boolean;
+ stable_upgradeable: boolean;
+}
- const DEV_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/latest/';
- const DEV_RELNOTES_URL =
- 'https://github.com/emsesp/EMS-ESP32/blob/dev/CHANGELOG_LATEST.md';
+interface VersionInfo {
+ name: string;
+ published_at?: string;
+}
- const { send: sendCheckUpgrade } = useRequest(
- (versions: string) => callAction({ action: 'checkUpgrade', param: versions }),
- {
- immediate: false
- }
- ).onSuccess((event) => {
- const data = event.data as {
- emsesp_version: string;
- dev_upgradeable: boolean;
- stable_upgradeable: boolean;
- };
- setDevUpgradeAvailable(data.dev_upgradeable);
- setStableUpgradeAvailable(data.stable_upgradeable);
- });
+// Memoized components for better performance
+const VersionInfoDialog = memo(
+ ({
+ showVersionInfo,
+ latestVersion,
+ latestDevVersion,
+ locale,
+ LL,
+ onClose
+ }: {
+ showVersionInfo: number;
+ latestVersion?: VersionInfo;
+ latestDevVersion?: VersionInfo;
+ locale: string;
+ LL: any;
+ onClose: () => void;
+ }) => {
+ if (showVersionInfo === 0) return null;
- const {
- data: data,
- send: loadData,
- error
- } = useRequest(SystemApi.readSystemStatus).onSuccess((event) => {
- // older version of EMS-ESP using ESP32 (not S3) and no PSRAM, can't use OTA because of SSL support in HttpClient
- if (event.data.arduino_version.startsWith('Tasmota')) {
- setDownloadOnly(true);
- }
- setUsingDevVersion(event.data.emsesp_version.includes('dev'));
- });
+ const isStable = showVersionInfo === 1;
+ const version = isStable ? latestVersion : latestDevVersion;
+ const relNotesUrl = isStable ? STABLE_RELNOTES_URL : DEV_RELNOTES_URL;
- const { send: sendUploadURL } = useRequest(
- (url: string) => callAction({ action: 'uploadURL', param: url }),
- {
- immediate: false
- }
- );
-
- // called immediately to get the latest versions on page load
- const { data: latestVersion } = useRequest(getStableVersion);
- const { data: latestDevVersion } = useRequest(getDevVersion);
-
- useEffect(() => {
- if (latestVersion && latestDevVersion) {
- sendCheckUpgrade(latestDevVersion.name + ',' + latestVersion.name)
- .catch((error: Error) => {
- toast.error('Failed to check for upgrades: ' + error.message);
- })
- .finally(() => {
- setInternetLive(true);
- });
- }
- }, [latestVersion, latestDevVersion]);
-
- const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
- const DIVISIONS: Array<{ amount: number; name: string }> = [
- { amount: 60, name: 'seconds' },
- { amount: 60, name: 'minutes' },
- { amount: 24, name: 'hours' },
- { amount: 7, name: 'days' },
- { amount: 4.34524, name: 'weeks' },
- { amount: 12, name: 'months' },
- { amount: Number.POSITIVE_INFINITY, name: 'years' }
- ];
- function formatTimeAgo(date: Date) {
- let duration = (date.getTime() - new Date().getTime()) / 1000;
- for (let i = 0; i < DIVISIONS.length; i++) {
- const division = DIVISIONS[i];
- if (division && Math.abs(duration) < division.amount) {
- return rtf.format(
- Math.round(duration),
- division.name as Intl.RelativeTimeFormatUnit
- );
- }
- if (division) {
- duration /= division.amount;
- }
- }
- return rtf.format(0, 'seconds');
+ return (
+
+ );
}
+);
- const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
- immediate: false
- });
+const InstallDialog = memo(
+ ({
+ openInstallDialog,
+ fetchDevVersion,
+ latestVersion,
+ latestDevVersion,
+ downloadOnly,
+ platform,
+ LL,
+ onClose,
+ onInstall
+ }: {
+ openInstallDialog: boolean;
+ fetchDevVersion: boolean;
+ latestVersion?: VersionInfo;
+ latestDevVersion?: VersionInfo;
+ downloadOnly: boolean;
+ platform: string;
+ LL: any;
+ onClose: () => void;
+ onInstall: (url: string) => void;
+ }) => {
+ const binURL = useMemo(() => {
+ if (!latestVersion || !latestDevVersion) return '';
- const doRestart = async () => {
- setRestarting(true);
- await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
- (error: Error) => {
- toast.error(error.message);
- }
- );
- };
+ const version = fetchDevVersion ? latestDevVersion : latestVersion;
+ const filename = `EMS-ESP-${version.name.replaceAll('.', '_')}-${platform}.bin`;
- const getBinURL = (showingDev: boolean) => {
- if (!internetLive) {
- return '';
- }
-
- const filename =
- 'EMS-ESP-' +
- (showingDev ? latestDevVersion.name : latestVersion.name).replaceAll(
- '.',
- '_'
- ) +
- '-' +
- getPlatform() +
- '.bin';
- return showingDev
- ? DEV_URL + filename
- : STABLE_URL + 'v' + latestVersion.name + '/' + filename;
- };
-
- const getPlatform = () => {
- return (
- [data.esp_platform, data.flash_chip_size >= 16384 ? '16MB' : '4MB'].join('-') +
- (data.psram ? '+' : '')
- );
- };
-
- const installFirmwareURL = async (url: string) => {
- await sendUploadURL(url).catch((error: Error) => {
- toast.error(error.message);
- });
- setRestarting(true);
- };
-
- useLayoutTitle('EMS-ESP Firmware');
-
- const renderInstallDialog = () => {
- const binURL = getBinURL(fetchDevVersion);
+ return fetchDevVersion
+ ? `${DEV_URL}${filename}`
+ : `${STABLE_URL}v${version.name}/${filename}`;
+ }, [fetchDevVersion, latestVersion, latestDevVersion, platform]);
return (
-