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 ( + + Version Information + + + + {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))} + /> + + )} + + + + + + + + ); } +); - 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 ( - closeInstallDialog()} - > + - {LL.UPDATE() + - ' ' + - (fetchDevVersion ? LL.DEVELOPMENT() : LL.STABLE()) + - ' Firmware'} + {`${LL.UPDATE()} ${fetchDevVersion ? LL.DEVELOPMENT() : LL.STABLE()} Firmware`} @@ -211,7 +191,7 @@ const Version = () => { ); - }; + } +); - const showFirmwareDialog = (useDevVersion: boolean) => { +// Helper function moved outside component +const getPlatform = (data: VersionData): string => { + return `${data.esp_platform}-${data.flash_chip_size >= 16384 ? '16MB' : '4MB'}${data.psram ? '+' : ''}`; +}; + +const Version = () => { + const { LL, locale } = useI18nContext(); + const { me } = useContext(AuthenticatedContext); + + // State management + 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); + const [showVersionInfo, setShowVersionInfo] = useState(0); + + // API calls with optimized error handling + const { send: sendCheckUpgrade } = useRequest( + (versions: string) => callAction({ action: 'checkUpgrade', param: versions }), + { immediate: false } + ).onSuccess((event) => { + const data = event.data as UpgradeCheckData; + setDevUpgradeAvailable(data.dev_upgradeable); + setStableUpgradeAvailable(data.stable_upgradeable); + }); + + const { + data, + send: loadData, + error + } = useRequest(SystemApi.readSystemStatus).onSuccess((event) => { + const systemData = event.data as VersionData; + if (systemData.arduino_version.startsWith('Tasmota')) { + setDownloadOnly(true); + } + setUsingDevVersion(systemData.emsesp_version.includes('dev')); + }); + + const { send: sendUploadURL } = useRequest( + (url: string) => callAction({ action: 'uploadURL', param: url }), + { immediate: false } + ); + + const { data: latestVersion } = useRequest(getStableVersion); + const { data: latestDevVersion } = useRequest(getDevVersion); + + const { send: sendAPI } = useRequest((data: APIcall) => API(data), { + immediate: false + }); + + // Memoized values + const platform = useMemo(() => (data ? getPlatform(data) : ''), [data]); + const isDev = useMemo( + () => data?.emsesp_version.includes('dev') ?? false, + [data?.emsesp_version] + ); + + const doRestart = useCallback(async () => { + setRestarting(true); + await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( + (error: Error) => { + toast.error(error.message); + } + ); + }, [sendAPI]); + + const installFirmwareURL = useCallback( + async (url: string) => { + await sendUploadURL(url).catch((error: Error) => { + toast.error(error.message); + }); + setRestarting(true); + }, + [sendUploadURL] + ); + + const showFirmwareDialog = useCallback((useDevVersion: boolean) => { setFetchDevVersion(useDevVersion); setOpenInstallDialog(true); - }; + }, []); - const closeInstallDialog = () => { + const closeInstallDialog = useCallback(() => { setOpenInstallDialog(false); - }; + }, []); - const showButtons = (showingDev: boolean) => { - const choice = showingDev - ? !usingDevVersion - ? LL.SWITCH_RELEASE_TYPE(LL.DEVELOPMENT()) - : devUpgradeAvailable - ? LL.UPDATE_AVAILABLE() - : undefined - : usingDevVersion - ? LL.SWITCH_RELEASE_TYPE(LL.STABLE()) - : stableUpgradeAvailable - ? LL.UPDATE_AVAILABLE() - : undefined; + const handleVersionInfoClose = useCallback(() => { + setShowVersionInfo(0); + }, []); + + // Effect for checking upgrades + useEffect(() => { + if (latestVersion && latestDevVersion) { + const versions = `${latestDevVersion.name},${latestVersion.name}`; + sendCheckUpgrade(versions) + .catch((error: Error) => { + toast.error(`Failed to check for upgrades: ${error.message}`); + }) + .finally(() => { + setInternetLive(true); + }); + } + }, [latestVersion, latestDevVersion, sendCheckUpgrade]); + + useLayoutTitle('EMS-ESP Firmware'); + + // Memoized button rendering logic + const showButtons = useCallback( + (showingDev: boolean) => { + const choice = showingDev + ? !usingDevVersion + ? LL.SWITCH_RELEASE_TYPE(LL.DEVELOPMENT()) + : devUpgradeAvailable + ? LL.UPDATE_AVAILABLE() + : undefined + : usingDevVersion + ? LL.SWITCH_RELEASE_TYPE(LL.STABLE()) + : stableUpgradeAvailable + ? LL.UPDATE_AVAILABLE() + : undefined; + + if (!choice) { + return ( + <> + + + {LL.LATEST_VERSION(usingDevVersion ? LL.DEVELOPMENT() : LL.STABLE())} + + + + ); + } + + if (!me.admin) return null; - if (!choice) { return ( - <> - - - {LL.LATEST_VERSION(usingDevVersion ? LL.DEVELOPMENT() : LL.STABLE())} - - - + ); - } + }, + [ + usingDevVersion, + devUpgradeAvailable, + stableUpgradeAvailable, + me.admin, + LL, + showFirmwareDialog + ] + ); - if (!me.admin) { - return; - } - - return ( - - ); - }; - - const content = () => { + const content = useMemo(() => { if (!data) { return ; } - const isDev = data.emsesp_version.includes('dev'); - return ( <> @@ -344,7 +433,7 @@ const Version = () => { - {getPlatform()} + {platform}   ( {data.psram ? ( @@ -436,15 +525,10 @@ const Version = () => { - - {latestVersion.name} - - {latestVersion.published_at && ( - -  ( - {formatTimeAgo(new Date(latestVersion.published_at))}) - - )} + {latestVersion?.name} + setShowVersionInfo(1)}> + + {showButtons(false)} @@ -454,15 +538,10 @@ const Version = () => { - - {latestDevVersion.name} - - {latestDevVersion.published_at && ( - -  ( - {formatTimeAgo(new Date(latestDevVersion.published_at))}) - - )} + {latestDevVersion?.name} + setShowVersionInfo(2)}> + + {showButtons(true)} @@ -476,7 +555,25 @@ const Version = () => { )} {me.admin && ( <> - {renderInstallDialog()} + + {LL.UPLOAD()} @@ -486,11 +583,30 @@ const Version = () => { ); - }; + }, [ + data, + error, + loadData, + LL, + platform, + isDev, + internetLive, + latestVersion, + latestDevVersion, + showVersionInfo, + locale, + openInstallDialog, + fetchDevVersion, + downloadOnly, + me.admin, + showButtons, + handleVersionInfoClose, + closeInstallDialog, + installFirmwareURL, + doRestart + ]); - return ( - {restarting ? : content()} - ); + return {restarting ? : content}; }; -export default Version; +export default memo(Version); diff --git a/interface/src/utils/time.ts b/interface/src/utils/time.ts index 8189fef84..3ff3b7f0e 100644 --- a/interface/src/utils/time.ts +++ b/interface/src/utils/time.ts @@ -1,18 +1,135 @@ -const LOCALE_FORMAT = new Intl.DateTimeFormat([...window.navigator.languages], { - day: 'numeric', - month: 'short', - year: 'numeric', - hour: 'numeric', - minute: 'numeric', - second: 'numeric', - hour12: false -}); +// Cache for formatters to avoid recreation +const formatterCache = new Map(); +const rtfCache = new Map(); -export const formatDateTime = (dateTime: string) => - LOCALE_FORMAT.format(new Date(dateTime.substring(0, 19))); +// Pre-computed time divisions for relative time formatting +const TIME_DIVISIONS = [ + { amount: 60, name: 'seconds' as const }, + { amount: 60, name: 'minutes' as const }, + { amount: 24, name: 'hours' as const }, + { amount: 7, name: 'days' as const }, + { amount: 4.34524, name: 'weeks' as const }, + { amount: 12, name: 'months' as const }, + { amount: Number.POSITIVE_INFINITY, name: 'years' as const } +] as const; -export const formatLocalDateTime = (date: Date) => - new Date(date.getTime() - date.getTimezoneOffset() * 60000) - .toISOString() - .slice(0, -1) - .substring(0, 19); +/** + * Get or create a cached DateTimeFormat instance + */ +function getDateTimeFormatter( + options: Intl.DateTimeFormatOptions +): Intl.DateTimeFormat { + const key = JSON.stringify(options); + if (!formatterCache.has(key)) { + formatterCache.set( + key, + new Intl.DateTimeFormat([...window.navigator.languages], options) + ); + } + return formatterCache.get(key)!; +} + +/** + * Get or create a cached RelativeTimeFormat instance + */ +function getRelativeTimeFormatter(locale: string): Intl.RelativeTimeFormat { + if (!rtfCache.has(locale)) { + rtfCache.set(locale, new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })); + } + return rtfCache.get(locale)!; +} + +/** + * Format a date as relative time (e.g., "2 hours ago", "in 3 days") + */ +function formatTimeAgo(locale: string, date: Date): string { + const now = Date.now(); + const targetTime = date.getTime(); + let duration = (targetTime - now) / 1000; + + const rtf = getRelativeTimeFormatter(locale); + + // Use for...of for better performance and readability + for (const division of TIME_DIVISIONS) { + if (Math.abs(duration) < division.amount) { + return rtf.format(Math.round(duration), division.name); + } + duration /= division.amount; + } + + return rtf.format(0, 'seconds'); +} + +/** + * Format a date-time string to locale-specific format + */ +export const formatDateTime = (dateTime: string): string => { + if (!dateTime || typeof dateTime !== 'string') { + return 'Invalid date'; + } + + try { + // Extract only the first 19 characters (YYYY-MM-DDTHH:mm:ss) + const cleanDateTime = dateTime.substring(0, 19); + const date = new Date(cleanDateTime); + + if (isNaN(date.getTime())) { + return 'Invalid date'; + } + + const formatter = getDateTimeFormatter({ + day: 'numeric', + month: 'short', + year: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + hour12: false + }); + + return formatter.format(date); + } catch (error) { + console.warn('Error formatting date:', error); + return 'Invalid date'; + } +}; + +/** + * Convert a Date object to local date-time string (ISO format without timezone) + */ +export const formatLocalDateTime = (date: Date): string => { + if (!(date instanceof Date) || isNaN(date.getTime())) { + return 'Invalid date'; + } + + // Calculate local time offset in milliseconds + const offsetMs = date.getTimezoneOffset() * 60000; + const localTime = date.getTime() - offsetMs; + + // Convert to ISO string and remove timezone info + return new Date(localTime).toISOString().slice(0, 19); +}; + +/** + * Format a date with both short date format and relative time + */ +export const prettyDateTime = (locale: string, date: Date): string => { + if (!(date instanceof Date) || isNaN(date.getTime())) { + return 'Invalid date'; + } + + if (!locale || typeof locale !== 'string') { + locale = 'en'; + } + + const shortFormatter = getDateTimeFormatter({ + day: 'numeric', + month: 'short', + year: 'numeric' + }); + + const shortDate = shortFormatter.format(date); + const relativeTime = formatTimeAgo(locale, date); + + return `${shortDate} (${relativeTime})`; +}; diff --git a/pio_local.ini_example b/pio_local.ini_example index 8f37b479f..5b976e0f8 100644 --- a/pio_local.ini_example +++ b/pio_local.ini_example @@ -19,18 +19,18 @@ my_build_flags = [platformio] -; default_envs = s_16M_P ; BBQKees E32V2 +default_envs = s_16M_P ; BBQKees E32V2 +; default_envs = build_webUI ; build the web interface only ; default_envs = s3_16M_P ; BBQKees S3 ; default_envs = s_4M ; BBQKees older S32, 4MB no psram ; default_envs = s_16M ; BBQKees newer S32 V2, 16MB no psram ; default_envs = c6 ; XIAO ESP32C -default_envs = debug +; default_envs = debug [env] -; uncomment if you want to upload the firmware via OTA (must have upload_protocol = custom) extra_scripts = - pre:scripts/build_interface.py ; builds the WebUI - comment out if you don't want to build each time + pre:scripts/build_interface.py ; builds the WebUI scripts/rename_fw.py ; renames the firmware .bin file - comment out if not needed scripts/upload.py ; optionally upload the firmware via OTA (must have upload_protocol = custom) @@ -46,28 +46,6 @@ custom_password = admin ; upload_protocol = custom ; custom_emsesp_ip = or ems-esp.local -; upload_protocol = custom -; custom_emsesp_ip = 10.10.10.93 ; S3 -custom_emsesp_ip = 192.168.1.223 ; E32V2 -; custom_emsesp_ip = 192.168.1.173 ; S32 -; custom_emsesp_ip = 192.168.1.59 ; S32 (old) 4MB blue board - -; lib_deps = -; bblanchon/ArduinoJson @ 7.4.2 -; ESP32Async/AsyncTCP @ 3.4.9 -; ESP32Async/ESPAsyncWebServer @ 3.8.1 -; ; file://${PROJECT_DIR}/../modules/EMS-ESP-Modules -; https://github.com/emsesp/EMS-ESP-Modules.git @ 1.0.8 - -; [espressif32_base_16M] -; framework = arduino -; board_build.partitions = partitions/esp32_partition_16M.csv -; board_upload.flash_size = 16MB -; board_build.app_partition_name = app0 -; ; platform = espressif32@6.12.0 ; Arduino Core v2.0.17 / IDF v4.4.7 -; ; platform = https://github.com/pioarduino/platform-espressif32/releases/download/54.03.20/platform-espressif32.zip ; Arduino Core v3.2.0 / ESP-IDF v5.4.1 -; platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.30/platform-espressif32.zip ; Arduino Core 3.3.0, IDF 5.5.0 - ; ** debug settings ** ; to be used with an ESP32-S3 which has an onboard JTAG, connect the OTG USB port to the PC ; if using an external JTAG board like the ESP-PROG set debug_tool = esp-prog, and use zadig to set the correct driver for the USB port diff --git a/platformio.ini b/platformio.ini index 2719bff6a..6d13b354e 100644 --- a/platformio.ini +++ b/platformio.ini @@ -98,9 +98,7 @@ build_flags = build_unflags = ${common.unbuild_flags} extra_scripts = - ; pre:scripts/build_interface.py ; builds the WebUI - scripts/rename_fw.py ; renames the firmware .bin file - ; scripts/upload.py ; optionally upload the firmware via OTA (if upload_protocol = custom) + post:scripts/rename_fw.py ; renames the firmware .bin file monitor_speed = 115200 monitor_filters = direct build_type = release @@ -181,29 +179,14 @@ build_flags = ${common.build_flags} -DBOARD_C6 -; -; Building and testing natively, standalone without an ESP32. -; See https://docs.platformio.org/en/latest/platforms/native.html -; -; It will generate an executable which when run will show the EMS-ESP Console where you can run tests using the `test` command. -; -; See https://docs.platformio.org/en/latest/core/installation/shell-commands.html#piocore-install-shell-commands -; -; to build and run directly on linux: pio run -e native -t exec -; -; to build and run on Windows, it needs winsock for the console input so: -; - For the first time, install Msys2 (https://www.msys2.org/) and the GCC compiler with `run pacman -S mingw-w64-ucrt-x86_64-gcc` -; - Then, build with `pio run -e native` to create the program.exe file -; - run by calling the executable from the Mysys shell e.g. `C:/msys64/msys2_shell.cmd -defterm -here -no-start -ucrt64 -c /.pio/build/native/program.exe` -; - or use with Windows Terminal https://www.msys2.org/docs/terminals/ -; +; foundation for building and testing natively, standalone without an ESP32. [env:native] platform = native build_type = debug +build_flags = build_src_flags = - -DARDUINOJSON_ENABLE_ARDUINO_STRING=1 -DEMSESP_STANDALONE -DEMSESP_TEST - -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_TX_MODE=8 -DEMSESP_DEFAULT_VERSION=\"3.7.3-dev.0\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32\" + -DARDUINOJSON_ENABLE_ARDUINO_STRING=1 -std=gnu++17 -Og -ggdb -Wall -Wextra -Wno-unused-parameter -Wno-sign-compare -Wno-missing-braces @@ -274,8 +257,45 @@ lib_deps = Unity test_testing_command = ${platformio.build_dir}/${this.__env__}/program +; +; Building and testing locally on OS, which we call "standalone" without an ESP32. +; See https://docs.platformio.org/en/latest/platforms/native.html +; +; It will generate an executable which when run will show the EMS-ESP Console where you can run tests using the `test` command. +; +; See https://docs.platformio.org/en/latest/core/installation/shell-commands.html#piocore-install-shell-commands +; +; to build and run directly on linux: pio run -e standalone -t exec +; +; to build and run on Windows, it needs winsock for the console input so: +; - For the first time, install Msys2 (https://www.msys2.org/) and the GCC compiler with `run pacman -S mingw-w64-ucrt-x86_64-gcc` +; - Then, build with `pio run -e standalone` to create the program.exe file +; - run by calling the executable from the Mysys shell e.g. `C:/msys64/msys2_shell.cmd -defterm -here -no-start -ucrt64 -c /.pio/build/native/program.exe` +; - or use with Windows Terminal https://www.msys2.org/docs/terminals/ +; +[env:standalone] +extends = env:native +build_flags = + -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_TX_MODE=8 -DEMSESP_DEFAULT_VERSION=\"3.7.3-dev.0\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32\" + +; Modbus +; Creating the modbus registers is a multi-step process. Before it was in a shell script called generate_csv_and_headers.sh +; but now moved to pio so everything is in python and cross-platform. The logic is as follows: +; 1. create a dummy modbus_entity_parameters.hpp file so the first pass compiles +; 2. compile the EMS-ESPcode with the EMSESP_MODBUS flag set +; 3. run the entity_dump test command and generate the dump_entities.csv file +; 4. use the dump_entities.csv file with update_modbus_registers.py script to generate the modbus_entity_parameters.hpp file +; 5. clean up everything and start again with the EMSESP_STANDALONE flag set +; 6. run the entity_dump test command again to create the real dump_entities.csv file +; 7. create the Modbus-Entity-Registers.md file +; 8. create the dump_telegrams.csv file +; +; To run this in pio use the steps +; pio run -e build_modbus +; pio run -e build_standalone -t clean -t build + # builds the modbus_entity_parameters.hpp header file -# pio run -e build_modbus -t build +# pio run -e build_modbus [env:build_modbus] extends = env:native targets = build @@ -287,8 +307,7 @@ custom_test_command = entity_dump custom_output_file = dump_entities.csv custom_post_script = scripts/build_modbus_entity_parameters_post.py -; builds the real dump_entities.csv and dump_telegrams.csv files -; and the Modbus-Entity-Registers.md file +; builds the real dump_entities.csv and dump_telegrams.csv files, and also the Modbus-Entity-Registers.md file ; to be run after build_modbus with: pio run -e build_standalone -t clean -t build [env:build_standalone] extends = env:native diff --git a/scripts/build_interface.py b/scripts/build_interface.py index 8b967cded..c38776914 100755 --- a/scripts/build_interface.py +++ b/scripts/build_interface.py @@ -110,7 +110,7 @@ def build_webUI(*args, **kwargs): env.Exit(1) env.Exit(0) -# Create custom target that only runs the script +# Create custom target that only runs the script and then exits, without continuing with the pio workflow env.AddCustomTarget( name="build", dependencies=None, diff --git a/scripts/generate_csv_and_headers.sh b/scripts/generate_csv_and_headers.sh new file mode 100644 index 000000000..fc2254ea5 --- /dev/null +++ b/scripts/generate_csv_and_headers.sh @@ -0,0 +1,64 @@ +#!/bin/sh + +# Builds the dump_*.csv files, modbus headers and modbus documentation. +# Run as `sh scripts/generate_csv_and_headers.sh` from the root of the repository. + +## +## IMPORTANT NOTE! +## This script is not used anymore. It is kept for reference only. +## It has been replaced with two pio targets: build_modbus and build_standalone. +## + +# create a dummy modbus_entity_parameters.hpp so the first pass compiles +cat >./src/core/modbus_entity_parameters.hpp < Modbus::modbus_register_mappings = {}; + +} // namespace emsesp + +// clang-format on + +EOL + +# First generate Modbus entity parameters +# build the modbus_entity_parameters.hpp header file +make clean +make -s ARGS=-DEMSESP_MODBUS +rm -f ./src/core/modbus_entity_parameters.hpp ./docs/dump_entities.csv +echo "test entity_dump" | ./emsesp | python3 ./scripts/strip_csv.py > ./docs/dump_entities.csv +cat ./docs/dump_entities.csv | python3 ./scripts/update_modbus_registers.py > ./src/core/modbus_entity_parameters.hpp + +# regenerate dump_entities.csv but without the Modbus entity parameters +make clean +make -s ARGS=-DEMSESP_STANDALONE +rm -f ./docs/dump_entities.csv +echo "test entity_dump" | ./emsesp | python3 ./scripts/strip_csv.py > ./docs/dump_entities.csv + +# generate Modbus doc - Modbus-Entity-Registers.md used in the emsesp.org documentation +rm -f ./docs/Modbus-Entity-Registers.md +cat ./docs/dump_entities.csv | python3 ./scripts/generate-modbus-register-doc.py > ./docs/Modbus-Entity-Registers.md + +# dump_telegrams.csv +rm -f ./docs/dump_telegrams.csv +echo "test telegram_dump" | ./emsesp | python3 ./scripts/strip_csv.py > ./docs/dump_telegrams.csv + +ls -al ./src/core/modbus_entity_parameters.hpp +ls -al ./docs/Modbus-Entity-Registers.md +ls -al ./docs/dump_entities.csv +ls -al ./docs/dump_telegrams.csv \ No newline at end of file