Merge remote-tracking branch 'origin/dev' into core3

This commit is contained in:
proddy
2026-05-02 09:35:31 +02:00
85 changed files with 6841 additions and 5121 deletions

View File

@@ -77,3 +77,23 @@ jobs:
files: | files: |
CHANGELOG_LATEST.md CHANGELOG_LATEST.md
./build/firmware/*.* ./build/firmware/*.*
- name: Update version in Cloudflare KV store
if: github.repository == 'emsesp/EMS-ESP32'
env:
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }}
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
VERSION: ${{ steps.build_info.outputs.VERSION }}
run: |
JSON_DATA=$(jq -n \
--arg version "$VERSION" \
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{version: $version, date: $date}')
echo "JSON_DATA: $JSON_DATA"
curl -sS --fail-with-body \
-X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_NAMESPACE_ID}/values/dev" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "$JSON_DATA"
echo

View File

@@ -27,10 +27,17 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Enable Corepack - name: Enable Corepack
run: corepack enable pnpm run: corepack enable pnpm
- name: Get the EMS-ESP version
id: build_info
run: |
version=`grep -E '^#define EMSESP_APP_VERSION' ./src/emsesp_version.h | awk -F'"' '{print $2}'`
echo "VERSION=$version" >> $GITHUB_OUTPUT
- name: Install PlatformIO - name: Install PlatformIO
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
@@ -61,3 +68,23 @@ jobs:
files: | files: |
CHANGELOG.md CHANGELOG.md
./build/firmware/*.* ./build/firmware/*.*
- name: Update version in Cloudflare KV store
if: github.repository == 'emsesp/EMS-ESP32'
env:
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }}
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
VERSION: ${{ steps.build_info.outputs.VERSION }}
run: |
JSON_DATA=$(jq -n \
--arg version "$VERSION" \
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{version: $version, date: $date}')
echo "JSON_DATA: $JSON_DATA"
curl -sS --fail-with-body \
-X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_NAMESPACE_ID}/values/stable" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "$JSON_DATA"
echo

40
.github/workflows/update_versions.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: 'Update versions'
on:
workflow_dispatch:
permissions:
contents: write
jobs:
update-version:
name: 'Update versions in Cloudflare KV store'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Get and Send EMS-ESP version to Cloudflare
env:
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }}
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
run: |
version=$(grep -E '^#define EMSESP_APP_VERSION' ./src/emsesp_version.h | awk -F'"' '{print $2}')
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
KV_ENV="stable"
else
KV_ENV="dev"
fi
JSON_DATA=$(jq -n \
--arg version "$version" \
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{version: $version, date: $date}')
echo "KV_ENV: $KV_ENV"
echo "JSON_DATA: $JSON_DATA"
curl -sS --fail-with-body \
-X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_NAMESPACE_ID}/values/${KV_ENV}" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "$JSON_DATA"
echo

2
.gitignore vendored
View File

@@ -64,7 +64,7 @@ words-found-verbose.txt
# sonarlint # sonarlint
compile_commands.json compile_commands.json
# pioarduino + hybrid # other files
managed_components managed_components
dependencies.lock dependencies.lock
CMakeLists.txt CMakeLists.txt

View File

@@ -3,7 +3,7 @@
"version": "3.8.2", "version": "3.8.2",
"description": "EMS-ESP WebUI", "description": "EMS-ESP WebUI",
"homepage": "https://emsesp.org", "homepage": "https://emsesp.org",
"author": "proddy, emsesp.org", "author": "emsesp.org",
"license": "MIT", "license": "MIT",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -28,14 +28,11 @@
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.0.0", "@mui/icons-material": "^9.0.0",
"@mui/material": "^9.0.0", "@mui/material": "^9.0.0",
"@preact/compat": "^18.3.2",
"@table-library/react-table-library": "4.1.15", "@table-library/react-table-library": "4.1.15",
"alova": "^3.5.1", "alova": "^3.5.1",
"async-validator": "^4.2.5", "async-validator": "^4.2.5",
"etag": "^1.8.1", "etag": "^1.8.1",
"formidable": "^3.5.4",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"magic-string": "^0.30.21",
"mime-types": "^3.0.2", "mime-types": "^3.0.2",
"preact": "^10.29.1", "preact": "^10.29.1",
"react": "^19.2.5", "react": "^19.2.5",
@@ -47,24 +44,21 @@
"typescript": "^6.0.3" "typescript": "^6.0.3"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.29.0",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@preact/compat": "^18.3.2",
"@preact/preset-vite": "^2.10.5", "@preact/preset-vite": "^2.10.5",
"@trivago/prettier-plugin-sort-imports": "^6.0.2", "@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"axe-core": "^4.11.3",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"eslint": "^10.2.1", "eslint": "^10.2.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"rollup-plugin-visualizer": "^7.0.1", "rollup-plugin-visualizer": "^7.0.1",
"terser": "^5.46.1", "terser": "^5.46.2",
"typescript-eslint": "^8.59.0", "typescript-eslint": "^8.59.1",
"vite": "^8.0.9", "vite": "^8.0.10",
"vite-plugin-imagemin": "^0.6.1" "vite-plugin-imagemin": "^0.6.1"
}, },
"packageManager": "pnpm@10.33.1+sha512.05ba3c1d5d1c18f68df06470d74055e62d41fc110a0c660db1b2dfb2785327f04cf0f68345d4609bc52089e7fa0343c31593b2f9594e2c5d5da426230acc9820" "packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8"
} }

5038
interface/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useState } from 'react'; import { memo, useEffect, useState } from 'react';
import { ToastContainer, Zoom } from 'react-toastify'; import { ToastContainer, Zoom } from 'react-toastify';
import AppRouting from 'AppRouting'; import AppRouting from 'AppRouting';
@@ -46,19 +46,17 @@ const App = memo(() => {
const [wasLoaded, setWasLoaded] = useState(false); const [wasLoaded, setWasLoaded] = useState(false);
const [locale, setLocale] = useState<Locales>('en'); const [locale, setLocale] = useState<Locales>('en');
// 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);
await loadLocaleAsync(newLocale);
setWasLoaded(true);
}, []);
useEffect(() => { useEffect(() => {
const initializeLocale = async () => {
const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector);
const newLocale = (localStorage.getItem('lang') || browserLocale) as Locales;
localStorage.setItem('lang', newLocale);
setLocale(newLocale);
await loadLocaleAsync(newLocale);
setWasLoaded(true);
};
void initializeLocale(); void initializeLocale();
}, [initializeLocale]); }, []);
if (!wasLoaded) return null; if (!wasLoaded) return null;

View File

@@ -16,6 +16,7 @@ import DownloadUpload from 'app/settings/DownloadUpload';
import MqttSettings from 'app/settings/MqttSettings'; import MqttSettings from 'app/settings/MqttSettings';
import NTPSettings from 'app/settings/NTPSettings'; import NTPSettings from 'app/settings/NTPSettings';
import Settings from 'app/settings/Settings'; import Settings from 'app/settings/Settings';
import Version from 'app/settings/Version';
import Network from 'app/settings/network/Network'; import Network from 'app/settings/network/Network';
import Security from 'app/settings/security/Security'; import Security from 'app/settings/security/Security';
import APStatus from 'app/status/APStatus'; import APStatus from 'app/status/APStatus';
@@ -26,7 +27,6 @@ import NTPStatus from 'app/status/NTPStatus';
import NetworkStatus from 'app/status/NetworkStatus'; import NetworkStatus from 'app/status/NetworkStatus';
import Status from 'app/status/Status'; import Status from 'app/status/Status';
import SystemLog from 'app/status/SystemLog'; import SystemLog from 'app/status/SystemLog';
import Version from 'app/status/Version';
import { Layout } from 'components'; import { Layout } from 'components';
import { AuthenticatedContext } from 'contexts/authentication'; import { AuthenticatedContext } from 'contexts/authentication';
@@ -49,11 +49,11 @@ const AuthenticatedRouting = memo(() => {
<Route path="/status/ntp" element={<NTPStatus />} /> <Route path="/status/ntp" element={<NTPStatus />} />
<Route path="/status/ap" element={<APStatus />} /> <Route path="/status/ap" element={<APStatus />} />
<Route path="/status/network" element={<NetworkStatus />} /> <Route path="/status/network" element={<NetworkStatus />} />
<Route path="/status/version" element={<Version />} />
{me.admin && ( {me.admin && (
<> <>
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/settings/version" element={<Version />} />
<Route path="/settings/application" element={<ApplicationSettings />} /> <Route path="/settings/application" element={<ApplicationSettings />} />
<Route path="/settings/mqtt" element={<MqttSettings />} /> <Route path="/settings/mqtt" element={<MqttSettings />} />
<Route path="/settings/ntp" element={<NTPSettings />} /> <Route path="/settings/ntp" element={<NTPSettings />} />

View File

@@ -43,7 +43,6 @@ const SignIn = memo(() => {
} }
}); });
// Memoize callback to prevent recreation on every render
const updateLoginRequestValue = useMemo( const updateLoginRequestValue = useMemo(
() => () =>
updateValue((updater) => updateValue((updater) =>
@@ -65,7 +64,7 @@ const SignIn = memo(() => {
}); });
}, [callSignIn, signInRequest, LL]); }, [callSignIn, signInRequest, LL]);
const validateAndSignIn = useCallback(async () => { const validateAndSignIn = async () => {
setProcessing(true); setProcessing(true);
SIGN_IN_REQUEST_VALIDATOR.messages({ SIGN_IN_REQUEST_VALIDATOR.messages({
required: LL.IS_REQUIRED('%s') required: LL.IS_REQUIRED('%s')
@@ -77,7 +76,7 @@ const SignIn = memo(() => {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
setProcessing(false); setProcessing(false);
} }
}, [signInRequest, signIn, LL]); };
const submitOnEnter = useMemo(() => onEnterCallback(signIn), [signIn]); const submitOnEnter = useMemo(() => onEnterCallback(signIn), [signIn]);

View File

@@ -57,12 +57,3 @@ export const alovaInstance = createAlova({
onSuccess: handleResponse onSuccess: handleResponse
} }
}); });
export const alovaInstanceGH = createAlova({
baseURL:
process.env.NODE_ENV === 'development'
? '/gh'
: 'https://api.github.com/repos/emsesp/EMS-ESP32/releases',
statesHook: ReactHook,
requestAdapter: xhrRequestAdapter()
});

View File

@@ -1,6 +1,6 @@
import type { LogSettings, SystemStatus } from 'types'; import type { LogSettings, SystemStatus } from 'types';
import { alovaInstance, alovaInstanceGH } from './endpoints'; import { alovaInstance } from './endpoints';
// systemStatus - also used to ping in System Monitor for pinging // systemStatus - also used to ping in System Monitor for pinging
export const readSystemStatus = () => export const readSystemStatus = () =>
@@ -13,29 +13,6 @@ export const updateLogSettings = (data: LogSettings) =>
alovaInstance.Post('/rest/logSettings', data); alovaInstance.Post('/rest/logSettings', data);
export const fetchLogES = () => alovaInstance.Get('/es/log'); export const fetchLogES = () => alovaInstance.Get('/es/log');
// Get versions from GitHub
// cache for 10 minutes to stop getting the IP blocked by GitHub
export const getStableVersion = () =>
alovaInstanceGH.Get('latest', {
cacheFor: 60 * 10 * 1000,
transform(response: { data: { name: string; published_at: string } }) {
return {
name: response.data.name.substring(1),
published_at: response.data.published_at
};
}
});
export const getDevVersion = () =>
alovaInstanceGH.Get('tags/latest', {
cacheFor: 60 * 10 * 1000,
transform(response: { data: { name: string; published_at: string } }) {
return {
name: response.data.name.split(/\s+/).splice(-1)[0]?.substring(1) || '',
published_at: response.data.published_at
};
}
});
const UPLOAD_TIMEOUT = 60000; // 1 minute const UPLOAD_TIMEOUT = 60000; // 1 minute
export const uploadFile = (file: File) => { export const uploadFile = (file: File) => {

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react'; import { useState } from 'react';
import { useBlocker } from 'react-router'; import { useBlocker } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -57,20 +57,18 @@ const CustomEntities = () => {
initialData: [] initialData: []
}); });
const intervalCallback = useCallback(() => { useInterval(() => {
if (!dialogOpen && !numChanges) { if (!dialogOpen && !numChanges) {
void fetchEntities(); void fetchEntities();
} }
}, [dialogOpen, numChanges, fetchEntities]); });
useInterval(intervalCallback);
const { send: writeEntities } = useRequest( const { send: writeEntities } = useRequest(
(data: Entities) => writeCustomEntities(data), (data: Entities) => writeCustomEntities(data),
{ immediate: false } { immediate: false }
); );
const hasEntityChanged = useCallback((ei: EntityItem) => { const hasEntityChanged = (ei: EntityItem) => {
return ( return (
ei.id !== ei.o_id || ei.id !== ei.o_id ||
ei.ram !== ei.o_ram || ei.ram !== ei.o_ram ||
@@ -86,21 +84,19 @@ const CustomEntities = () => {
ei.deleted !== ei.o_deleted || ei.deleted !== ei.o_deleted ||
(ei.value || '') !== (ei.o_value || '') (ei.value || '') !== (ei.o_value || '')
); );
}, []); };
const entity_theme = useMemo( const entity_theme = useTheme({
() => Table: `
useTheme({
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(60px, 1fr)) minmax(80px, auto) 80px 80px 80px 120px; --data-table-library_grid-template-columns: repeat(1, minmax(60px, 1fr)) minmax(80px, auto) 80px 80px 80px 120px;
`, `,
BaseRow: ` BaseRow: `
font-size: 14px; font-size: 14px;
.td { .td {
height: 32px; height: 32px;
} }
`, `,
BaseCell: ` BaseCell: `
&:nth-of-type(1) { &:nth-of-type(1) {
padding: 8px; padding: 8px;
} }
@@ -120,7 +116,7 @@ const CustomEntities = () => {
text-align: center; text-align: center;
} }
`, `,
HeaderRow: ` HeaderRow: `
text-transform: uppercase; text-transform: uppercase;
background-color: black; background-color: black;
color: #90CAF9; color: #90CAF9;
@@ -129,7 +125,7 @@ const CustomEntities = () => {
height: 36px; height: 36px;
} }
`, `,
Row: ` Row: `
background-color: #1e1e1e; background-color: #1e1e1e;
position: relative; position: relative;
cursor: pointer; cursor: pointer;
@@ -140,11 +136,9 @@ const CustomEntities = () => {
background-color: #177ac9; background-color: #177ac9;
} }
` `
}), });
[]
);
const saveEntities = useCallback(async () => { const saveEntities = async () => {
await writeEntities({ await writeEntities({
entities: entities entities: entities
.filter((ei: EntityItem) => !ei.deleted) .filter((ei: EntityItem) => !ei.deleted)
@@ -173,44 +167,41 @@ const CustomEntities = () => {
await fetchEntities(); await fetchEntities();
setNumChanges(0); setNumChanges(0);
}); });
}, [entities, writeEntities, LL, fetchEntities]); };
const editEntityItem = useCallback((ei: EntityItem) => { const editEntityItem = (ei: EntityItem) => {
setCreating(false); setCreating(false);
setSelectedEntityItem(ei); setSelectedEntityItem(ei);
setDialogOpen(true); setDialogOpen(true);
}, []); };
const onDialogClose = useCallback(() => { const onDialogClose = () => {
setDialogOpen(false); setDialogOpen(false);
}, []); };
const onDialogCancel = useCallback(async () => { const onDialogCancel = async () => {
await fetchEntities().then(() => { await fetchEntities().then(() => {
setNumChanges(0); setNumChanges(0);
}); });
}, [fetchEntities]); };
const onDialogSave = useCallback( const onDialogSave = (updatedItem: EntityItem) => {
(updatedItem: EntityItem) => { setDialogOpen(false);
setDialogOpen(false); void updateState(readCustomEntities(), (data: EntityItem[]) => {
void updateState(readCustomEntities(), (data: EntityItem[]) => { const new_data = creating
const new_data = creating ? [
? [ ...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id),
...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id), updatedItem
updatedItem ]
] : data.map((ei) =>
: data.map((ei) => ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei
ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei );
); setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length); return new_data;
return new_data; });
}); };
},
[creating, hasEntityChanged]
);
const onDialogDup = useCallback((item: EntityItem) => { const onDialogDup = (item: EntityItem) => {
setCreating(true); setCreating(true);
setSelectedEntityItem({ setSelectedEntityItem({
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID), id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
@@ -228,9 +219,9 @@ const CustomEntities = () => {
value: item.value value: item.value
}); });
setDialogOpen(true); setDialogOpen(true);
}, []); };
const addEntityItem = useCallback(() => { const addEntityItem = () => {
setCreating(true); setCreating(true);
setSelectedEntityItem({ setSelectedEntityItem({
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID), id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
@@ -248,30 +239,27 @@ const CustomEntities = () => {
value: '' value: ''
}); });
setDialogOpen(true); setDialogOpen(true);
}, []); };
const formatValue = useCallback((value: unknown, uom: number) => { const formatValue = (value: unknown, uom: number) => {
return value === undefined return value === undefined
? '' ? ''
: typeof value === 'number' : typeof value === 'number'
? new Intl.NumberFormat().format(value) + ? new Intl.NumberFormat().format(value) +
(uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`) (uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`)
: `${value as string}${uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`}`; : `${value as string}${uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`}`;
}, []); };
const showHex = useCallback((value: number, digit: number) => { const showHex = (value: number, digit: number) => {
return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`; return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`;
}, []); };
const filteredAndSortedEntities = useMemo( const filteredAndSortedEntities =
() => entities
entities ?.filter((ei: EntityItem) => !ei.deleted)
?.filter((ei: EntityItem) => !ei.deleted) .sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [];
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [],
[entities]
);
const renderEntity = useCallback(() => { const renderEntity = () => {
if (!entities) { if (!entities) {
return ( return (
<FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} /> <FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} />
@@ -328,17 +316,7 @@ const CustomEntities = () => {
)} )}
</Table> </Table>
); );
}, [ };
entities,
error,
fetchEntities,
entity_theme,
editEntityItem,
LL,
filteredAndSortedEntities,
showHex,
formatValue
]);
return ( return (
<SectionContent> <SectionContent>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -68,14 +68,10 @@ const CustomEntitiesDialog = ({
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<EntityItem>(selectedItem); const [editItem, setEditItem] = useState<EntityItem>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = useMemo( const updateFormValue = updateValue(
() => setEditItem as unknown as React.Dispatch<
updateValue( React.SetStateAction<Record<string, unknown>>
setEditItem as unknown as React.Dispatch< >
React.SetStateAction<Record<string, unknown>>
>
),
[]
); );
useEffect(() => { useEffect(() => {
@@ -105,16 +101,16 @@ const CustomEntitiesDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = useCallback( const handleClose = (
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => { _event: React.SyntheticEvent,
if (reason !== 'backdropClick') { reason: 'backdropClick' | 'escapeKeyDown'
onClose(); ) => {
} if (reason !== 'backdropClick') {
}, onClose();
[onClose] }
); };
const save = useCallback(async () => { const save = async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, editItem); await validate(validator, editItem);
@@ -138,27 +134,21 @@ const CustomEntitiesDialog = ({
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}, [validator, editItem, onSave]); };
const remove = useCallback(() => { const remove = () => {
const itemWithDeleted = { ...editItem, deleted: true }; onSave({ ...editItem, deleted: true });
onSave(itemWithDeleted); };
}, [editItem, onSave]);
const dup = useCallback(() => { const dup = () => {
onDup(editItem); onDup(editItem);
}, [editItem, onDup]); };
// Memoize UOM menu items to avoid recreating on every render const uomMenuItems = DeviceValueUOM_s.map((val, i) => (
const uomMenuItems = useMemo( <MenuItem key={val} value={i}>
() => {val}
DeviceValueUOM_s.map((val, i) => ( </MenuItem>
<MenuItem key={val} value={i}> ));
{val}
</MenuItem>
)),
[]
);
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
import { useBlocker, useLocation } from 'react-router'; import { useBlocker, useLocation } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -171,19 +171,17 @@ const Customizations = () => {
); );
}; };
const entities_theme = useMemo( const entities_theme = useTheme({
() => Table: `
useTheme({
Table: `
--data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto); --data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto);
`, `,
BaseRow: ` BaseRow: `
font-size: 14px; font-size: 14px;
.td { .td {
height: 32px; height: 32px;
} }
`, `,
BaseCell: ` BaseCell: `
&:nth-of-type(3) { &:nth-of-type(3) {
text-align: right; text-align: right;
} }
@@ -194,7 +192,7 @@ const Customizations = () => {
text-align: right; text-align: right;
} }
`, `,
HeaderRow: ` HeaderRow: `
text-transform: uppercase; text-transform: uppercase;
background-color: black; background-color: black;
color: #90CAF9; color: #90CAF9;
@@ -206,7 +204,7 @@ const Customizations = () => {
text-align: center; text-align: center;
} }
`, `,
Row: ` Row: `
background-color: #1e1e1e; background-color: #1e1e1e;
position: relative; position: relative;
cursor: pointer; cursor: pointer;
@@ -222,7 +220,7 @@ const Customizations = () => {
background-color: #177ac9; background-color: #177ac9;
} }
`, `,
Cell: ` Cell: `
&:nth-of-type(2) { &:nth-of-type(2) {
padding: 8px; padding: 8px;
} }
@@ -236,9 +234,7 @@ const Customizations = () => {
padding-right: 8px; padding-right: 8px;
} }
` `
}), });
[]
);
function hasEntityChanged(de: DeviceEntity) { function hasEntityChanged(de: DeviceEntity) {
return ( return (
@@ -287,26 +283,23 @@ const Customizations = () => {
return value as string; return value as string;
} }
const isCommand = useCallback((de: DeviceEntity) => { const isCommand = (de: DeviceEntity) => {
return de.n && de.n[0] === '!'; return de.n && de.n[0] === '!';
}, []); };
const formatName = useCallback( const formatName = (de: DeviceEntity, withShortname: boolean) => {
(de: DeviceEntity, withShortname: boolean) => { let name: string;
let name: string; if (isCommand(de)) {
if (isCommand(de)) { name = de.t
name = de.t ? `${LL.COMMAND(1)}: ${de.t} ${de.n?.slice(1)}`
? `${LL.COMMAND(1)}: ${de.t} ${de.n?.slice(1)}` : `${LL.COMMAND(1)}: ${de.n?.slice(1)}`;
: `${LL.COMMAND(1)}: ${de.n?.slice(1)}`; } else if (de.cn && de.cn !== '') {
} else if (de.cn && de.cn !== '') { name = de.t ? `${de.t} ${de.cn}` : de.cn;
name = de.t ? `${de.t} ${de.cn}` : de.cn; } else {
} else { name = de.t ? `${de.t} ${de.n}` : de.n || '';
name = de.t ? `${de.t} ${de.n}` : de.n || ''; }
} return withShortname ? `${name} ${de.id}` : name;
return withShortname ? `${name} ${de.id}` : name; };
},
[LL]
);
const getMaskNumber = (newMask: string[]) => { const getMaskNumber = (newMask: string[]) => {
let new_mask = 0; let new_mask = 0;
@@ -336,33 +329,27 @@ const Customizations = () => {
return new_masks; return new_masks;
}; };
const filter_entity = useCallback( const filter_entity = (de: DeviceEntity) =>
(de: DeviceEntity) => (de.m & selectedFilters || !selectedFilters) &&
(de.m & selectedFilters || !selectedFilters) && formatName(de, true).toLowerCase().includes(search.toLowerCase());
formatName(de, true).toLowerCase().includes(search.toLowerCase()),
[selectedFilters, search, formatName]
);
const maskDisabled = useCallback( const maskDisabled = (set: boolean) => {
(set: boolean) => { setDeviceEntities((prev) =>
setDeviceEntities((prev) => prev.map((de) => {
prev.map((de) => { if (filter_entity(de)) {
if (filter_entity(de)) { const excludeMask =
const excludeMask = DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE; return {
return { ...de,
...de, m: set ? de.m | excludeMask : de.m & ~excludeMask
m: set ? de.m | excludeMask : de.m & ~excludeMask };
}; }
} return de;
return de; })
}) );
); };
},
[filter_entity]
);
const resetCustomization = useCallback(async () => { const resetCustomization = async () => {
try { try {
await sendResetCustomizations(); await sendResetCustomizations();
toast.info(LL.CUSTOMIZATIONS_RESTART()); toast.info(LL.CUSTOMIZATIONS_RESTART());
@@ -372,30 +359,27 @@ const Customizations = () => {
setConfirmReset(false); setConfirmReset(false);
setRestarting(true); setRestarting(true);
} }
}, [sendResetCustomizations, LL]); };
const onDialogClose = () => { const onDialogClose = () => {
setDialogOpen(false); setDialogOpen(false);
}; };
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => { const updateDeviceEntity = (updatedItem: DeviceEntity) => {
setDeviceEntities( setDeviceEntities(
(prev) => (prev) =>
prev?.map((de) => prev?.map((de) =>
de.id === updatedItem.id ? { ...de, ...updatedItem } : de de.id === updatedItem.id ? { ...de, ...updatedItem } : de
) ?? [] ) ?? []
); );
}, []); };
const onDialogSave = useCallback( const onDialogSave = (updatedItem: DeviceEntity) => {
(updatedItem: DeviceEntity) => { setDialogOpen(false);
setDialogOpen(false); updateDeviceEntity(updatedItem);
updateDeviceEntity(updatedItem); };
},
[updateDeviceEntity]
);
const editDeviceEntity = useCallback((de: DeviceEntity) => { const editDeviceEntity = (de: DeviceEntity) => {
if (de.n === undefined || (de.n && de.n[0] === '!')) { if (de.n === undefined || (de.n && de.n[0] === '!')) {
return; return;
} }
@@ -406,9 +390,9 @@ const Customizations = () => {
setSelectedDeviceEntity(de); setSelectedDeviceEntity(de);
setDialogOpen(true); setDialogOpen(true);
}, []); };
const saveCustomization = useCallback(async () => { const saveCustomization = async () => {
if (!devices || !deviceEntities || selectedDevice === -1) { if (!devices || !deviceEntities || selectedDevice === -1) {
return; return;
} }
@@ -441,9 +425,9 @@ const Customizations = () => {
.finally(() => { .finally(() => {
setOriginalSettings(deviceEntities); setOriginalSettings(deviceEntities);
}); });
}, [devices, deviceEntities, selectedDevice, sendCustomizationEntities, LL]); };
const renameDevice = useCallback(async () => { const renameDevice = async () => {
await sendDeviceName({ await sendDeviceName({
id: selectedDevice, id: selectedDevice,
name: selectedDeviceName, name: selectedDeviceName,
@@ -459,14 +443,7 @@ const Customizations = () => {
setRename(false); setRename(false);
await fetchCoreData(); await fetchCoreData();
}); });
}, [ };
selectedDevice,
selectedDeviceName,
selectedDeviceBrand,
sendDeviceName,
LL,
fetchCoreData
]);
const renderDeviceList = () => ( const renderDeviceList = () => (
<> <>
@@ -562,10 +539,7 @@ const Customizations = () => {
</> </>
); );
const filteredEntities = useMemo( const filteredEntities = deviceEntities.filter((de) => filter_entity(de));
() => deviceEntities.filter((de) => filter_entity(de)),
[deviceEntities, filter_entity]
);
const renderDeviceData = () => { const renderDeviceData = () => {
return ( return (

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { memo, useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
@@ -57,23 +57,16 @@ const CustomizationsDialog = ({
const [editItem, setEditItem] = useState<DeviceEntity>(selectedItem); const [editItem, setEditItem] = useState<DeviceEntity>(selectedItem);
const [error, setError] = useState<boolean>(false); const [error, setError] = useState<boolean>(false);
const updateFormValue = useMemo( const updateFormValue = updateValue(
() => setEditItem as unknown as React.Dispatch<
updateValue( React.SetStateAction<Record<string, unknown>>
setEditItem as unknown as React.Dispatch< >
React.SetStateAction<Record<string, unknown>>
>
),
[]
); );
const isWriteableNumber = useMemo( const isWriteableNumber =
() => typeof editItem.v === 'number' &&
typeof editItem.v === 'number' && editItem.w &&
editItem.w && !(editItem.m & DeviceEntityMask.DV_READONLY);
!(editItem.m & DeviceEntityMask.DV_READONLY),
[editItem.v, editItem.w, editItem.m]
);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -82,16 +75,16 @@ const CustomizationsDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = useCallback( const handleClose = (
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => { _event: React.SyntheticEvent,
if (reason !== 'backdropClick') { reason: 'backdropClick' | 'escapeKeyDown'
onClose(); ) => {
} if (reason !== 'backdropClick') {
}, onClose();
[onClose] }
); };
const save = useCallback(() => { const save = () => {
if ( if (
isWriteableNumber && isWriteableNumber &&
editItem.mi && editItem.mi &&
@@ -102,34 +95,31 @@ const CustomizationsDialog = ({
} else { } else {
onSave(editItem); onSave(editItem);
} }
}, [isWriteableNumber, editItem, onSave]); };
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => { const updateDeviceEntity = (updatedItem: DeviceEntity) => {
setEditItem((prev) => ({ ...prev, m: updatedItem.m })); setEditItem((prev) => ({ ...prev, m: updatedItem.m }));
}, []); };
const dialogTitle = useMemo(() => `${LL.EDIT()} ${LL.ENTITY()}`, [LL]);
const writeableIcon = useMemo(
() =>
editItem.w ? (
<DoneIcon color="success" sx={{ fontSize: ICON_SIZE }} />
) : (
<CloseIcon color="error" sx={{ fontSize: ICON_SIZE }} />
),
[editItem.w]
);
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>{dialogTitle}</DialogTitle> <DialogTitle>{`${LL.EDIT()} ${LL.ENTITY()}`}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<LabelValue label={LL.ID_OF(LL.ENTITY())} value={editItem.id} /> <LabelValue label={LL.ID_OF(LL.ENTITY())} value={editItem.id} />
<LabelValue <LabelValue
label={`${LL.DEFAULT(1)} ${LL.ENTITY_NAME(1)}`} label={`${LL.DEFAULT(1)} ${LL.ENTITY_NAME(1)}`}
value={editItem.n} value={editItem.n}
/> />
<LabelValue label={LL.WRITEABLE()} value={writeableIcon} /> <LabelValue
label={LL.WRITEABLE()}
value={
editItem.w ? (
<DoneIcon color="success" sx={{ fontSize: ICON_SIZE }} />
) : (
<CloseIcon color="error" sx={{ fontSize: ICON_SIZE }} />
)
}
/>
<Box sx={{ mt: 1, mb: 2 }}> <Box sx={{ mt: 1, mb: 2 }}>
<EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} /> <EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} />

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { memo, useContext, useEffect, useState } from 'react';
import { IconContext } from 'react-icons/lib'; import { IconContext } from 'react-icons/lib';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -77,40 +77,35 @@ const Dashboard = memo(() => {
} }
); );
const deviceValueDialogSave = useCallback( const deviceValueDialogSave = async (devicevalue: DeviceValue) => {
async (devicevalue: DeviceValue) => { if (!selectedDashboardItem) {
if (!selectedDashboardItem) { return;
return; }
} const id = selectedDashboardItem.parentNode.id; // this is the parent ID
const id = selectedDashboardItem.parentNode.id; // this is the parent ID await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v })
await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v }) .then(() => {
.then(() => { toast.success(LL.WRITE_CMD_SENT());
toast.success(LL.WRITE_CMD_SENT()); })
}) .catch((error: Error) => {
.catch((error: Error) => { toast.error(error.message);
toast.error(error.message); })
}) .finally(() => {
.finally(() => { setDeviceValueDialogOpen(false);
setDeviceValueDialogOpen(false); setSelectedDashboardItem(undefined);
setSelectedDashboardItem(undefined); });
}); };
},
[selectedDashboardItem, sendDeviceValue, LL]
);
const dashboard_theme = useMemo( const dashboard_theme = useTheme({
() => Table: `
useTheme({
Table: `
--data-table-library_grid-template-columns: minmax(80px, auto) 120px 32px; --data-table-library_grid-template-columns: minmax(80px, auto) 120px 32px;
`, `,
BaseRow: ` BaseRow: `
font-size: 14px; font-size: 14px;
.td { .td {
height: 28px; height: 28px;
} }
`, `,
Row: ` Row: `
cursor: pointer; cursor: pointer;
background-color: #1e1e1e; background-color: #1e1e1e;
&:nth-of-type(odd) .td { &:nth-of-type(odd) .td {
@@ -120,7 +115,7 @@ const Dashboard = memo(() => {
background-color: #177ac9; background-color: #177ac9;
}, },
`, `,
BaseCell: ` BaseCell: `
&:nth-of-type(2) { &:nth-of-type(2) {
text-align: right; text-align: right;
} }
@@ -128,9 +123,7 @@ const Dashboard = memo(() => {
text-align: right; text-align: right;
} }
` `
}), });
[]
);
const tree = useTree( const tree = useTree(
{ nodes: [...data.nodes] }, { nodes: [...data.nodes] },
@@ -164,79 +157,64 @@ const Dashboard = memo(() => {
} }
}); });
const nodeIds = useMemo(
() => data.nodes.map((item: DashboardItem) => item.id),
[data.nodes]
);
useEffect(() => { useEffect(() => {
const nodeIds = data.nodes.map((item: DashboardItem) => item.id);
showAll showAll
? tree.fns.onAddAll(nodeIds) // expand tree ? tree.fns.onAddAll(nodeIds) // expand tree
: tree.fns.onRemoveAll(); // collapse tree : tree.fns.onRemoveAll(); // collapse tree
}, [parentNodes]); }, [parentNodes]);
const showType = useCallback( const showType = (n?: string, t?: number) => {
(n?: string, t?: number) => { // if we have a name show it
// if we have a name show it if (n) {
if (n) { return n;
return n; }
if (t) {
// otherwise pick translation based on type
switch (t) {
case DeviceType.CUSTOM:
return LL.CUSTOM_ENTITIES(0);
case DeviceType.ANALOGSENSOR:
return LL.ANALOG_SENSORS();
case DeviceType.TEMPERATURESENSOR:
return LL.TEMP_SENSORS();
case DeviceType.SCHEDULER:
return LL.SCHEDULER();
default:
break;
} }
if (t) { }
// otherwise pick translation based on type return '';
switch (t) { };
case DeviceType.CUSTOM:
return LL.CUSTOM_ENTITIES(0);
case DeviceType.ANALOGSENSOR:
return LL.ANALOG_SENSORS();
case DeviceType.TEMPERATURESENSOR:
return LL.TEMP_SENSORS();
case DeviceType.SCHEDULER:
return LL.SCHEDULER();
default:
break;
}
}
return '';
},
[LL]
);
const showName = useCallback( const showName = (di: DashboardItem) => {
(di: DashboardItem) => { if (di.id < 100) {
if (di.id < 100) { // if its a device (parent node) and has entities
// if its a device (parent node) and has entities if (di.nodes?.length) {
if (di.nodes?.length) { return (
return ( <span style={{ fontSize: '15px' }}>
<span style={{ fontSize: '15px' }}> <DeviceIcon type_id={di.t ?? 0} />
<DeviceIcon type_id={di.t ?? 0} /> &nbsp;&nbsp;{showType(di.n, di.t)}
&nbsp;&nbsp;{showType(di.n, di.t)} <span style={{ color: 'lightblue' }}>&nbsp;({di.nodes?.length})</span>
<span style={{ color: 'lightblue' }}>&nbsp;({di.nodes?.length})</span> </span>
</span> );
);
}
} }
if (di.dv) { }
return <span>{di.dv.id.slice(2)}</span>; if (di.dv) {
} return <span>{di.dv.id.slice(2)}</span>;
return null; }
}, return null;
[showType] };
);
const hasMask = useCallback( const hasMask = (id: string, mask: number) =>
(id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask, (parseInt(id.slice(0, 2), 16) & mask) === mask;
[]
);
const editDashboardValue = useCallback( const editDashboardValue = (di: DashboardItem) => {
(di: DashboardItem) => { if (me.admin && di.dv?.c) {
if (me.admin && di.dv?.c) { setSelectedDashboardItem(di);
setSelectedDashboardItem(di); setDeviceValueDialogOpen(true);
setDeviceValueDialogOpen(true); }
} };
},
[me.admin]
);
const handleShowAll = ( const handleShowAll = (
_event: React.MouseEvent<HTMLElement>, _event: React.MouseEvent<HTMLElement>,
@@ -248,10 +226,9 @@ const Dashboard = memo(() => {
} }
}; };
const hasFavEntities = useMemo( const hasFavEntities = data.nodes.filter(
() => data.nodes.filter((item: DashboardItem) => item.id <= 90).length, (item: DashboardItem) => item.id <= 90
[data.nodes] ).length;
);
const renderContent = () => { const renderContent = () => {
if (!data) { if (!data) {

View File

@@ -4,7 +4,6 @@ import {
useContext, useContext,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useMemo,
useState useState
} from 'react'; } from 'react';
import { IconContext } from 'react-icons'; import { IconContext } from 'react-icons';
@@ -133,21 +132,19 @@ const Devices = memo(() => {
}; };
}, []); }, []);
const leftOffset = useCallback(() => { const leftOffset = () => {
const devicesWindow = document.getElementById('devices-window'); const devicesWindow = document.getElementById('devices-window');
if (!devicesWindow) return 0; if (!devicesWindow) return 0;
const { left, right } = devicesWindow.getBoundingClientRect(); const { left, right } = devicesWindow.getBoundingClientRect();
if (!left || !right) return 0; if (!left || !right) return 0;
return left + (right - left < 400 ? 0 : 200); return left + (right - left < 400 ? 0 : 200);
}, []); };
const common_theme = useMemo( const common_theme = useTheme({
() => BaseRow: `
useTheme({
BaseRow: `
font-size: 14px; font-size: 14px;
`, `,
HeaderRow: ` HeaderRow: `
text-transform: uppercase; text-transform: uppercase;
background-color: black; background-color: black;
color: #90CAF9; color: #90CAF9;
@@ -155,7 +152,7 @@ const Devices = memo(() => {
border-bottom: 1px solid #565656; border-bottom: 1px solid #565656;
} }
`, `,
Row: ` Row: `
cursor: pointer; cursor: pointer;
background-color: #1E1E1E; background-color: #1E1E1E;
.td { .td {
@@ -165,88 +162,78 @@ const Devices = memo(() => {
background-color: #177ac9; background-color: #177ac9;
} }
` `
}), });
[]
);
const device_theme = useMemo( const device_theme = useTheme([
() => common_theme,
useTheme([ {
common_theme, BaseRow: `
{ font-size: 15px;
BaseRow: ` .td {
font-size: 15px; height: 28px;
.td {
height: 28px;
}
`,
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px;
`,
HeaderRow: `
.th {
padding: 8px;
`,
Row: `
&:nth-of-type(odd) .td {
background-color: #303030;
},
&:hover .td {
background-color: #177ac9;
},
`
}
]),
[common_theme]
);
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%;
overflow-y: scroll;
::-webkit-scrollbar {
display:none;
} }
`, `,
BaseRow: ` Table: `
.td { --data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px;
height: 32px; `,
} HeaderRow: `
`, .th {
BaseCell: ` padding: 8px;
&:nth-of-type(1) { `,
border-left: 1px solid #177ac9; Row: `
}, &:nth-of-type(odd) .td {
&:nth-of-type(2) {
text-align: right;
},
&:nth-of-type(3) {
border-right: 1px solid #177ac9;
}
`,
HeaderRow: `
.th {
border-top: 1px solid #565656;
}
`,
Row: `
&:nth-of-type(odd) .td {
background-color: #303030; background-color: #303030;
}, },
&:hover .td { &:hover .td {
background-color: #177ac9; background-color: #177ac9;
},
`
}
]);
const data_theme = useTheme([
common_theme,
{
Table: `
--data-table-library_grid-template-columns: minmax(200px, auto) minmax(150px, auto) 40px;
height: auto;
max-height: 100%;
overflow-y: scroll;
::-webkit-scrollbar {
display:none;
} }
` `,
} BaseRow: `
]), .td {
[common_theme] height: 32px;
); }
`,
BaseCell: `
&:nth-of-type(1) {
border-left: 1px solid #177ac9;
},
&:nth-of-type(2) {
text-align: right;
},
&:nth-of-type(3) {
border-right: 1px solid #177ac9;
}
`,
HeaderRow: `
.th {
border-top: 1px solid #565656;
}
`,
Row: `
&:nth-of-type(odd) .td {
background-color: #303030;
},
&:hover .td {
background-color: #177ac9;
}
`
}
]);
const getSortIcon = (state: State, sortKey: unknown) => { const getSortIcon = (state: State, sortKey: unknown) => {
if (state.sortKey === sortKey && state.reverse) { if (state.sortKey === sortKey && state.reverse) {
@@ -345,10 +332,8 @@ const Devices = memo(() => {
return sc; return sc;
}; };
const hasMask = useCallback( const hasMask = (id: string, mask: number) =>
(id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask, (parseInt(id.slice(0, 2), 16) & mask) === mask;
[]
);
const handleDownloadCsv = () => { const handleDownloadCsv = () => {
const deviceIndex = coreData.devices.findIndex( const deviceIndex = coreData.devices.findIndex(
@@ -607,41 +592,35 @@ const Devices = memo(() => {
return; return;
} }
const showDeviceValue = useCallback((dv: DeviceValue) => { const showDeviceValue = (dv: DeviceValue) => {
setSelectedDeviceValue(dv); setSelectedDeviceValue(dv);
setDeviceValueDialogOpen(true); setDeviceValueDialogOpen(true);
}, []); };
const renderNameCell = useCallback( const renderNameCell = (dv: DeviceValue) => (
(dv: DeviceValue) => ( <>
<> {dv.id.slice(2)}&nbsp;
{dv.id.slice(2)}&nbsp; {hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && (
{hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && ( <StarIcon color="primary" sx={{ fontSize: 12 }} />
<StarIcon color="primary" sx={{ fontSize: 12 }} /> )}
)} {hasMask(dv.id, DeviceEntityMask.DV_READONLY) && (
{hasMask(dv.id, DeviceEntityMask.DV_READONLY) && ( <EditOffOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
<EditOffOutlinedIcon color="primary" sx={{ fontSize: 12 }} /> )}
)} {hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && (
{hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && ( <CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
<CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} /> )}
)} </>
</>
),
[hasMask]
); );
const shown_data = useMemo(() => { const shown_data = onlyFav
if (onlyFav) { ? deviceData.nodes.filter(
return deviceData.nodes.filter(
(dv: DeviceValue) => (dv: DeviceValue) =>
hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) &&
dv.id.slice(2).toLowerCase().includes(search.toLowerCase()) 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( const deviceIndex = coreData.devices.findIndex(
(d: Device) => d.id === device_select.state.id (d: Device) => d.id === device_select.state.id

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
@@ -52,7 +52,7 @@ const DevicesDialog = ({
const [editItem, setEditItem] = useState<DeviceValue>(selectedItem); const [editItem, setEditItem] = useState<DeviceValue>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = useMemo(() => updateValue(setEditItem), [setEditItem]); const updateFormValue = updateValue(setEditItem);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -61,7 +61,7 @@ const DevicesDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const save = useCallback(async () => { const save = async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, editItem); await validate(validator, editItem);
@@ -69,28 +69,25 @@ const DevicesDialog = ({
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}, [validator, editItem, onSave]); };
const setUom = useCallback( const setUom = (uom?: DeviceValueUOM) => {
(uom?: DeviceValueUOM) => { if (uom === undefined) {
if (uom === undefined) { return;
return; }
} switch (uom) {
switch (uom) { case DeviceValueUOM.HOURS:
case DeviceValueUOM.HOURS: return LL.HOURS();
return LL.HOURS(); case DeviceValueUOM.MINUTES:
case DeviceValueUOM.MINUTES: return LL.MINUTES();
return LL.MINUTES(); case DeviceValueUOM.SECONDS:
case DeviceValueUOM.SECONDS: return LL.SECONDS();
return LL.SECONDS(); default:
default: return DeviceValueUOM_s[uom];
return DeviceValueUOM_s[uom]; }
} };
},
[LL]
);
const showHelperText = useCallback((dv: DeviceValue) => { const showHelperText = (dv: DeviceValue) => {
if (dv.h) return dv.h; if (dv.h) return dv.h;
if (dv.l) return dv.l.join(' | '); if (dv.l) return dv.l.join(' | ');
if (dv.m !== undefined && dv.x !== undefined) { if (dv.m !== undefined && dv.x !== undefined) {
@@ -101,26 +98,16 @@ const DevicesDialog = ({
); );
} }
return undefined; return undefined;
}, []); };
const isCommand = useMemo( const isCommand = selectedItem.v === '' && selectedItem.c;
() => selectedItem.v === '' && selectedItem.c, const dialogTitle = isCommand
[selectedItem.v, selectedItem.c] ? LL.RUN_COMMAND()
); : writeable
? LL.CHANGE_VALUE()
const dialogTitle = useMemo(() => { : LL.VALUE(0);
if (isCommand) return LL.RUN_COMMAND(); const buttonLabel = isCommand ? LL.EXECUTE() : LL.UPDATE();
return writeable ? LL.CHANGE_VALUE() : LL.VALUE(0); const helperText = showHelperText(editItem);
}, [isCommand, writeable, LL]);
const buttonLabel = useMemo(() => {
return isCommand ? LL.EXECUTE() : LL.UPDATE();
}, [isCommand, LL]);
const helperText = useMemo(
() => showHelperText(editItem),
[editItem, showHelperText]
);
const valueLabel = LL.VALUE(0); const valueLabel = LL.VALUE(0);

View File

@@ -1,5 +1,3 @@
import { useCallback, useMemo } from 'react';
import { ToggleButton, ToggleButtonGroup } from '@mui/material'; import { ToggleButton, ToggleButtonGroup } from '@mui/material';
import OptionIcon from './OptionIcon'; import OptionIcon from './OptionIcon';
@@ -11,7 +9,6 @@ interface EntityMaskToggleProps {
de: DeviceEntity; de: DeviceEntity;
} }
// Available mask values
const MASK_VALUES = [ const MASK_VALUES = [
DeviceEntityMask.DV_WEB_EXCLUDE, // 1 DeviceEntityMask.DV_WEB_EXCLUDE, // 1
DeviceEntityMask.DV_API_MQTT_EXCLUDE, // 2 DeviceEntityMask.DV_API_MQTT_EXCLUDE, // 2
@@ -20,123 +17,95 @@ const MASK_VALUES = [
DeviceEntityMask.DV_DELETED // 128 DeviceEntityMask.DV_DELETED // 128
]; ];
/** const getMaskNumber = (newMask: string[]): number =>
* Converts an array of mask strings to a bitmask number newMask.reduce((mask, entry) => mask | Number(entry), 0);
*/
const getMaskNumber = (newMask: string[]): number => {
return newMask.reduce((mask, entry) => mask | Number(entry), 0);
};
/** const getMaskString = (mask: number): string[] =>
* Converts a bitmask number to an array of mask strings MASK_VALUES.filter((value) => (mask & value) === value).map((value) =>
*/
const getMaskString = (mask: number): string[] => {
return MASK_VALUES.filter((value) => (mask & value) === value).map((value) =>
String(value) String(value)
); );
};
/**
* Checks if a specific mask bit is set
*/
const hasMask = (mask: number, flag: number): boolean => (mask & flag) === flag; const hasMask = (mask: number, flag: number): boolean => (mask & flag) === flag;
const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => { const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
const handleChange = useCallback( const handleChange = (_event: unknown, mask: string[]) => {
(_event: unknown, mask: string[]) => { const newMask = getMaskNumber(mask);
// Convert selected masks to a number const updatedDe = { ...de };
const newMask = getMaskNumber(mask);
const updatedDe = { ...de };
// Apply business logic for mask interactions // If entity has no name and is set to readonly, also exclude from web
// If entity has no name and is set to readonly, also exclude from web if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) {
if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) { updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE;
updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE; } else {
} else { updatedDe.m = newMask;
updatedDe.m = newMask; }
}
// If excluded from web, cannot be favorite // If excluded from web, cannot be favorite
if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) { if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) {
updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE; updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE;
} }
onUpdate(updatedDe); onUpdate(updatedDe);
}, };
[de, onUpdate]
);
// Memoize mask string value
const maskStringValue = useMemo(() => getMaskString(de.m), [de.m]);
// Memoize disabled states
const isFavoriteDisabled = useMemo(
() =>
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED) ||
de.n === undefined,
[de.m, de.n]
);
const isReadonlyDisabled = useMemo(
() =>
!de.w ||
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE),
[de.w, de.m]
);
const isApiMqttExcludeDisabled = useMemo(
() => de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED),
[de.n, de.m]
);
const isWebExcludeDisabled = useMemo(
() => de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED),
[de.n, de.m]
);
// Memoize mask flag checks
const isFavoriteSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_FAVORITE),
[de.m]
);
const isReadonlySet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_READONLY),
[de.m]
);
const isApiMqttExcludeSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_API_MQTT_EXCLUDE),
[de.m]
);
const isWebExcludeSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE),
[de.m]
);
const isDeletedSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_DELETED),
[de.m]
);
return ( return (
<ToggleButtonGroup <ToggleButtonGroup
size="small" size="small"
color="secondary" color="secondary"
value={maskStringValue} value={getMaskString(de.m)}
onChange={handleChange} onChange={handleChange}
> >
<ToggleButton value="8" disabled={isFavoriteDisabled}> <ToggleButton
<OptionIcon type="favorite" isSet={isFavoriteSet} /> value="8"
disabled={
hasMask(
de.m,
DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED
) || de.n === undefined
}
>
<OptionIcon
type="favorite"
isSet={hasMask(de.m, DeviceEntityMask.DV_FAVORITE)}
/>
</ToggleButton> </ToggleButton>
<ToggleButton value="4" disabled={isReadonlyDisabled}> <ToggleButton
<OptionIcon type="readonly" isSet={isReadonlySet} /> value="4"
disabled={
!de.w ||
hasMask(
de.m,
DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE
)
}
>
<OptionIcon
type="readonly"
isSet={hasMask(de.m, DeviceEntityMask.DV_READONLY)}
/>
</ToggleButton> </ToggleButton>
<ToggleButton value="2" disabled={isApiMqttExcludeDisabled}> <ToggleButton
<OptionIcon type="api_mqtt_exclude" isSet={isApiMqttExcludeSet} /> value="2"
disabled={de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED)}
>
<OptionIcon
type="api_mqtt_exclude"
isSet={hasMask(de.m, DeviceEntityMask.DV_API_MQTT_EXCLUDE)}
/>
</ToggleButton> </ToggleButton>
<ToggleButton value="1" disabled={isWebExcludeDisabled}> <ToggleButton
<OptionIcon type="web_exclude" isSet={isWebExcludeSet} /> value="1"
disabled={de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED)}
>
<OptionIcon
type="web_exclude"
isSet={hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE)}
/>
</ToggleButton> </ToggleButton>
<ToggleButton value="128"> <ToggleButton value="128">
<OptionIcon type="deleted" isSet={isDeletedSet} /> <OptionIcon
type="deleted"
isSet={hasMask(de.m, DeviceEntityMask.DV_DELETED)}
/>
</ToggleButton> </ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
); );

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useContext, useMemo, useState } from 'react'; import { memo, useContext, useState } from 'react';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -60,6 +60,8 @@ const AVATAR_STYLES: SxProps<Theme> = {
bgcolor: '#72caf9' bgcolor: '#72caf9'
}; };
const SYSTEM_INFO_API: APIcall = { device: 'system', cmd: 'info', id: 0 };
const HelpComponent = () => { const HelpComponent = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
useLayoutTitle(LL.HELP()); useLayoutTitle(LL.HELP());
@@ -72,12 +74,7 @@ const HelpComponent = () => {
}); });
const [imgError, setImgError] = useState<boolean>(false); const [imgError, setImgError] = useState<boolean>(false);
const getCustomSupportMethod = useMemo( useRequest(callAction({ action: 'getCustomSupport' })).onSuccess((event) => {
() => callAction({ action: 'getCustomSupport' }),
[]
);
useRequest(getCustomSupportMethod).onSuccess((event) => {
if (event?.data && Object.keys(event.data).length !== 0) { if (event?.data && Object.keys(event.data).length !== 0) {
const { Support } = event.data as { const { Support } = event.data as {
Support: { img_url?: string; html?: string[] }; Support: { img_url?: string; html?: string[] };
@@ -100,47 +97,26 @@ const HelpComponent = () => {
toast.error(String(error.error?.message || 'An error occurred')); toast.error(String(error.error?.message || 'An error occurred'));
}); });
// Optimize API call memoization const helpLinks: HelpLink[] = [
const apiCall = useMemo(() => ({ device: 'system', cmd: 'info', id: 0 }), []); {
href: 'https://emsesp.org',
icon: <MenuBookIcon />,
label: () => LL.HELP_INFORMATION_1()
},
{
href: 'https://discord.gg/GP9DPSgeJq',
icon: <CommentIcon />,
label: () => LL.HELP_INFORMATION_2()
},
{
href: 'https://github.com/emsesp/EMS-ESP32/issues/new/choose',
icon: <GitHubIcon />,
label: () => LL.HELP_INFORMATION_3()
}
];
const handleDownloadSystemInfo = useCallback(() => { const imageSrc =
void sendAPI(apiCall); imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url;
}, [sendAPI, apiCall]);
const handleImageError = useCallback(() => {
setImgError(true);
}, []);
// Memoize help links to prevent recreation on every render
const helpLinks: HelpLink[] = useMemo(
() => [
{
href: 'https://emsesp.org',
icon: <MenuBookIcon />,
label: () => LL.HELP_INFORMATION_1()
},
{
href: 'https://discord.gg/GP9DPSgeJq',
icon: <CommentIcon />,
label: () => LL.HELP_INFORMATION_2()
},
{
href: 'https://github.com/emsesp/EMS-ESP32/issues/new/choose',
icon: <GitHubIcon />,
label: () => LL.HELP_INFORMATION_3()
}
],
[LL]
);
const isAdmin = useMemo(() => me?.admin ?? false, [me?.admin]);
// Memoize image source computation
const imageSrc = useMemo(
() =>
imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url,
[imgError, customSupport.img_url]
);
return ( return (
<SectionContent> <SectionContent>
@@ -157,13 +133,13 @@ const HelpComponent = () => {
component="img" component="img"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
sx={IMAGE_STYLES} sx={IMAGE_STYLES}
onError={handleImageError} onError={() => setImgError(true)}
src={imageSrc} src={imageSrc}
/> />
</Stack> </Stack>
)} )}
{isAdmin && ( {me?.admin && (
<List> <List>
{helpLinks.map(({ href, icon, label }) => ( {helpLinks.map(({ href, icon, label }) => (
<ListItem key={href}> <ListItem key={href}>
@@ -191,7 +167,7 @@ const HelpComponent = () => {
startIcon={<DownloadIcon />} startIcon={<DownloadIcon />}
variant="outlined" variant="outlined"
color="primary" color="primary"
onClick={handleDownloadSystemInfo} onClick={() => void sendAPI(SYSTEM_INFO_API)}
> >
{LL.SUPPORT_INFORMATION(0)} {LL.SUPPORT_INFORMATION(0)}
</Button> </Button>
@@ -214,7 +190,6 @@ const HelpComponent = () => {
); );
}; };
// Memoize the component to prevent unnecessary re-renders
const Help = memo(HelpComponent); const Help = memo(HelpComponent);
export default Help; export default Help;

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useMemo, useState } from 'react'; import { memo, useState } from 'react';
import { useBlocker } from 'react-router'; import { useBlocker } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -69,58 +69,53 @@ const Modules = () => {
} }
); );
const modules_theme = useTheme( const modules_theme = useTheme({
useMemo( Table: `
() => ({ --data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
Table: ` `,
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px; BaseRow: `
`, font-size: 14px;
BaseRow: ` .td {
font-size: 14px; height: 32px;
.td { }
height: 32px; `,
} BaseCell: `
`, &:nth-of-type(1) {
BaseCell: ` text-align: center;
&:nth-of-type(1) { }
text-align: center; `,
} HeaderRow: `
`, text-transform: uppercase;
HeaderRow: ` background-color: black;
text-transform: uppercase; color: #90CAF9;
background-color: black; .th {
color: #90CAF9; border-bottom: 1px solid #565656;
.th { height: 36px;
border-bottom: 1px solid #565656; }
height: 36px; `,
} Row: `
`, background-color: #1e1e1e;
Row: ` position: relative;
background-color: #1e1e1e; cursor: pointer;
position: relative; .td {
cursor: pointer; border-top: 1px solid #565656;
.td { border-bottom: 1px solid #565656;
border-top: 1px solid #565656; }
border-bottom: 1px solid #565656; &:hover .td {
} border-top: 1px solid #177ac9;
&:hover .td { border-bottom: 1px solid #177ac9;
border-top: 1px solid #177ac9; }
border-bottom: 1px solid #177ac9; &:nth-of-type(odd) .td {
} background-color: #303030;
&:nth-of-type(odd) .td { }
background-color: #303030; `
} });
`
}),
[]
)
);
const onDialogClose = useCallback(() => { const onDialogClose = () => {
setDialogOpen(false); setDialogOpen(false);
}, []); };
const updateModuleItem = useCallback((updatedItem: ModuleItem) => { const updateModuleItem = (updatedItem: ModuleItem) => {
void updateState(readModules(), (data: ModuleItem[]) => { void updateState(readModules(), (data: ModuleItem[]) => {
const new_data = data.map((mi) => const new_data = data.map((mi) =>
mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi
@@ -128,28 +123,25 @@ const Modules = () => {
setNumChanges(new_data.filter(hasModulesChanged).length); setNumChanges(new_data.filter(hasModulesChanged).length);
return new_data; return new_data;
}); });
}, []); };
const onDialogSave = useCallback( const onDialogSave = (updatedItem: ModuleItem) => {
(updatedItem: ModuleItem) => { setDialogOpen(false);
setDialogOpen(false); updateModuleItem(updatedItem);
updateModuleItem(updatedItem); };
},
[updateModuleItem]
);
const editModuleItem = useCallback((mi: ModuleItem) => { const editModuleItem = (mi: ModuleItem) => {
setSelectedModuleItem(mi); setSelectedModuleItem(mi);
setDialogOpen(true); setDialogOpen(true);
}, []); };
const onCancel = useCallback(async () => { const onCancel = async () => {
await fetchModules().then(() => { await fetchModules().then(() => {
setNumChanges(0); setNumChanges(0);
}); });
}, [fetchModules]); };
const saveModules = useCallback(async () => { const saveModules = async () => {
try { try {
await Promise.all( await Promise.all(
modules.map((condensed_mi: ModuleItem) => modules.map((condensed_mi: ModuleItem) =>
@@ -167,9 +159,9 @@ const Modules = () => {
await fetchModules(); await fetchModules();
setNumChanges(0); setNumChanges(0);
} }
}, [modules, updateModules, LL, fetchModules]); };
const content = useMemo(() => { const renderContent = () => {
if (!modules) { if (!modules) {
return ( return (
<FormLoader onRetry={fetchModules} errorMessage={error?.message || ''} /> <FormLoader onRetry={fetchModules} errorMessage={error?.message || ''} />
@@ -262,22 +254,12 @@ const Modules = () => {
</Box> </Box>
</> </>
); );
}, [ };
modules,
fetchModules,
error,
modules_theme,
editModuleItem,
LL,
numChanges,
onCancel,
saveModules
]);
return ( return (
<SectionContent> <SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null} {blocker ? <BlockNavigation blocker={blocker} /> : null}
{content} {renderContent()}
{selectedModuleItem && ( {selectedModuleItem && (
<ModulesDialog <ModulesDialog
open={dialogOpen} open={dialogOpen}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done'; import DoneIcon from '@mui/icons-material/Done';
@@ -37,14 +37,10 @@ const ModulesDialog = ({
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<ModuleItem>(selectedItem); const [editItem, setEditItem] = useState<ModuleItem>(selectedItem);
const updateFormValue = useMemo( const updateFormValue = updateValue(
() => setEditItem as unknown as React.Dispatch<
updateValue( React.SetStateAction<Record<string, unknown>>
setEditItem as unknown as React.Dispatch< >
React.SetStateAction<Record<string, unknown>>
>
),
[]
); );
// Sync form state when dialog opens or selected item changes // Sync form state when dialog opens or selected item changes
@@ -54,18 +50,13 @@ const ModulesDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleSave = useCallback(() => { const handleSave = () => {
onSave(editItem); onSave(editItem);
}, [editItem, onSave]); };
const dialogTitle = useMemo(
() => `${LL.EDIT()} ${editItem.key}`,
[LL, editItem.key]
);
return ( return (
<Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}> <Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}>
<DialogTitle>{dialogTitle}</DialogTitle> <DialogTitle>{`${LL.EDIT()} ${editItem.key}`}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Grid container> <Grid container>
<BlockFormControlLabel <BlockFormControlLabel

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
import { useBlocker } from 'react-router'; import { useBlocker } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -132,7 +132,7 @@ const Scheduler = () => {
} }
); );
const hasScheduleChanged = useCallback((si: ScheduleItem) => { const hasScheduleChanged = (si: ScheduleItem) => {
return ( return (
si.id !== si.o_id || si.id !== si.o_id ||
(si.name || '') !== (si.o_name || '') || (si.name || '') !== (si.o_name || '') ||
@@ -143,15 +143,13 @@ const Scheduler = () => {
si.cmd !== si.o_cmd || si.cmd !== si.o_cmd ||
si.value !== si.o_value si.value !== si.o_value
); );
}, []); };
const intervalCallback = useCallback(() => { useInterval(() => {
if (numChanges === 0) { if (numChanges === 0) {
void fetchSchedule(); void fetchSchedule();
} }
}, [numChanges, fetchSchedule]); }, INTERVAL_DELAY);
useInterval(intervalCallback, INTERVAL_DELAY);
useEffect(() => { useEffect(() => {
const formatter = new Intl.DateTimeFormat(locale, { const formatter = new Intl.DateTimeFormat(locale, {
@@ -169,7 +167,7 @@ const Scheduler = () => {
const schedule_theme = useTheme(scheduleTheme); const schedule_theme = useTheme(scheduleTheme);
const saveSchedule = useCallback(async () => { const saveSchedule = async () => {
try { try {
await updateSchedule({ await updateSchedule({
schedule: schedule schedule: schedule
@@ -192,46 +190,43 @@ const Scheduler = () => {
await fetchSchedule(); await fetchSchedule();
setNumChanges(0); setNumChanges(0);
} }
}, [LL, schedule, updateSchedule, fetchSchedule]); };
const editScheduleItem = useCallback((si: ScheduleItem) => { const editScheduleItem = (si: ScheduleItem) => {
setCreating(false); setCreating(false);
setSelectedScheduleItem(si); setSelectedScheduleItem(si);
setDialogOpen(true); setDialogOpen(true);
if (si.o_name === undefined) { if (si.o_name === undefined) {
si.o_name = si.name; si.o_name = si.name;
} }
}, []); };
const onDialogClose = useCallback(() => { const onDialogClose = () => {
setDialogOpen(false); setDialogOpen(false);
}, []); };
const onDialogCancel = useCallback(async () => { const onDialogCancel = async () => {
await fetchSchedule().then(() => { await fetchSchedule().then(() => {
setNumChanges(0); setNumChanges(0);
}); });
}, [fetchSchedule]); };
const onDialogSave = useCallback( const onDialogSave = (updatedItem: ScheduleItem) => {
(updatedItem: ScheduleItem) => { setDialogOpen(false);
setDialogOpen(false); void updateState(readSchedule(), (data: ScheduleItem[]) => {
void updateState(readSchedule(), (data: ScheduleItem[]) => { const new_data = creating
const new_data = creating ? [...data, updatedItem]
? [...data, updatedItem] : data.map((si) =>
: data.map((si) => si.id === updatedItem.id ? { ...si, ...updatedItem } : si
si.id === updatedItem.id ? { ...si, ...updatedItem } : si );
);
setNumChanges(new_data.filter((si) => hasScheduleChanged(si)).length); setNumChanges(new_data.filter((si) => hasScheduleChanged(si)).length);
return new_data; return new_data;
}); });
}, };
[creating, hasScheduleChanged]
);
const addScheduleItem = useCallback(() => { const addScheduleItem = () => {
setCreating(true); setCreating(true);
const newItem: ScheduleItem = { const newItem: ScheduleItem = {
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID), id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
@@ -239,36 +234,29 @@ const Scheduler = () => {
}; };
setSelectedScheduleItem(newItem); setSelectedScheduleItem(newItem);
setDialogOpen(true); setDialogOpen(true);
}, []); };
const filteredAndSortedSchedule = useMemo( const filteredAndSortedSchedule = schedule
() => .filter((si: ScheduleItem) => !si.deleted)
schedule .sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags);
.filter((si: ScheduleItem) => !si.deleted)
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags),
[schedule]
);
const dayBox = useCallback( const dayBox = (si: ScheduleItem, flag: number) => {
(si: ScheduleItem, flag: number) => { const dayIndex = Math.log(flag) / LOG_2;
const dayIndex = Math.log(flag) / LOG_2; const isActive = (si.flags & flag) === flag;
const isActive = (si.flags & flag) === flag;
return ( return (
<> <>
<Box> <Box>
<Typography sx={{ fontSize: 11 }} color={isActive ? 'primary' : 'grey'}> <Typography sx={{ fontSize: 11 }} color={isActive ? 'primary' : 'grey'}>
{dow[dayIndex]} {dow[dayIndex]}
</Typography> </Typography>
</Box> </Box>
<Divider orientation="vertical" flexItem /> <Divider orientation="vertical" flexItem />
</> </>
); );
}, };
[dow]
);
const scheduleType = useCallback((si: ScheduleItem) => { const scheduleType = (si: ScheduleItem) => {
const label = scheduleTypeLabels[si.flags]; const label = scheduleTypeLabels[si.flags];
return ( return (
@@ -278,9 +266,9 @@ const Scheduler = () => {
</Typography> </Typography>
</Box> </Box>
); );
}, []); };
const renderSchedule = useCallback(() => { const renderSchedule = () => {
if (!schedule) { if (!schedule) {
return ( return (
<FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} /> <FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} />
@@ -343,17 +331,7 @@ const Scheduler = () => {
)} )}
</Table> </Table>
); );
}, [ };
schedule,
error,
fetchSchedule,
filteredAndSortedSchedule,
schedule_theme,
editScheduleItem,
LL,
dayBox,
scheduleType
]);
return ( return (
<SectionContent> <SectionContent>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -60,6 +60,12 @@ const FLAG_VALUES = [
ScheduleFlag.SCHEDULE_SAT ScheduleFlag.SCHEDULE_SAT
] as const; ] as const;
const getFlagDOWnumber = (flags: string[]) =>
flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127;
const getFlagDOWstring = (f: number) =>
FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) => String(flag));
interface SchedulerDialogProps { interface SchedulerDialogProps {
open: boolean; open: boolean;
creating: boolean; creating: boolean;
@@ -84,14 +90,10 @@ const SchedulerDialog = ({
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [scheduleType, setScheduleType] = useState<ScheduleFlag>(); const [scheduleType, setScheduleType] = useState<ScheduleFlag>();
const updateFormValue = useMemo( const updateFormValue = updateValue(
() => setEditItem as unknown as React.Dispatch<
updateValue( React.SetStateAction<Record<string, unknown>>
setEditItem as unknown as React.Dispatch< >
React.SetStateAction<Record<string, unknown>>
>
),
[]
); );
useEffect(() => { useEffect(() => {
@@ -112,129 +114,95 @@ const SchedulerDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
// Helper function to handle save operations const handleSave = async (itemToSave: ScheduleItem) => {
const handleSave = useCallback( try {
async (itemToSave: ScheduleItem) => { setFieldErrors(undefined);
try { await validate(validator, itemToSave);
setFieldErrors(undefined); onSave(itemToSave);
await validate(validator, itemToSave); } catch (error) {
onSave(itemToSave); setFieldErrors((error as ValidationError).fieldErrors);
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
},
[validator, onSave]
);
const save = useCallback(async () => {
await handleSave(editItem);
}, [editItem, handleSave]);
const saveandactivate = useCallback(async () => {
await handleSave({ ...editItem, active: true });
}, [editItem, handleSave]);
const remove = useCallback(() => {
onSave({ ...editItem, deleted: true });
}, [editItem, onSave]);
// Optimize DOW flag conversion
const getFlagDOWnumber = useCallback((flags: string[]) => {
return flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127;
}, []);
const getFlagDOWstring = useCallback((f: number) => {
return FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) =>
String(flag)
);
}, []);
// Day of week display component
const DayOfWeekButton = useCallback(
(flag: number) => {
const dayIndex = Math.log2(flag);
const isSelected = (editItem.flags & flag) === flag;
return (
<Typography
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={isSelected ? 'primary' : 'grey'}
>
{dow[dayIndex]}
</Typography>
);
},
[editItem.flags, dow]
);
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
},
[onClose]
);
const handleScheduleTypeChange = useCallback(
(_event: React.SyntheticEvent<HTMLElement>, flag: ScheduleFlag | null) => {
if (flag !== null) {
setFieldErrors(undefined); // clear any validation errors
setScheduleType(flag);
// wipe the time field when changing the schedule type
// set the flags based on type
const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? FLAG_ALL_DAYS : flag;
setEditItem((prev) => ({ ...prev, time: '', flags: newFlags }));
}
},
[]
);
const handleDOWChange = useCallback(
(_event: React.SyntheticEvent<HTMLElement>, flags: string[]) => {
const newFlags =
getFlagDOWnumber(flags) === 0 ? FLAG_ALL_DAYS : getFlagDOWnumber(flags);
setEditItem((prev) => ({ ...prev, flags: newFlags }));
},
[getFlagDOWnumber]
);
// Memoize derived values
const isDaySchedule = useMemo(
() => scheduleType === ScheduleFlag.SCHEDULE_DAY,
[scheduleType]
);
const isTimerSchedule = useMemo(
() => scheduleType === ScheduleFlag.SCHEDULE_TIMER,
[scheduleType]
);
const isImmediateSchedule = useMemo(
() => scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE,
[scheduleType]
);
const needsTimeField = useMemo(
() => isDaySchedule || isTimerSchedule,
[isDaySchedule, isTimerSchedule]
);
const dowFlags = useMemo(
() => getFlagDOWstring(editItem.flags),
[editItem.flags, getFlagDOWstring]
);
const timeFieldValue = useMemo(() => {
if (needsTimeField) {
return editItem.time === '' ? DEFAULT_TIME : editItem.time;
} }
return editItem.time === DEFAULT_TIME ? '' : editItem.time; };
}, [editItem.time, needsTimeField]);
const timeFieldLabel = useMemo(() => { const save = async () => {
await handleSave(editItem);
};
const saveandactivate = async () => {
await handleSave({ ...editItem, active: true });
};
const remove = () => {
onSave({ ...editItem, deleted: true });
};
const DayOfWeekButton = (flag: number) => {
const dayIndex = Math.log2(flag);
const isSelected = (editItem.flags & flag) === flag;
return (
<Typography
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={isSelected ? 'primary' : 'grey'}
>
{dow[dayIndex]}
</Typography>
);
};
const handleClose = (
_event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') {
onClose();
}
};
const handleScheduleTypeChange = (
_event: React.SyntheticEvent<HTMLElement>,
flag: ScheduleFlag | null
) => {
if (flag !== null) {
setFieldErrors(undefined); // clear any validation errors
setScheduleType(flag);
// wipe the time field when changing the schedule type
// set the flags based on type
const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? FLAG_ALL_DAYS : flag;
setEditItem((prev) => ({ ...prev, time: '', flags: newFlags }));
}
};
const handleDOWChange = (
_event: React.SyntheticEvent<HTMLElement>,
flags: string[]
) => {
const newFlags =
getFlagDOWnumber(flags) === 0 ? FLAG_ALL_DAYS : getFlagDOWnumber(flags);
setEditItem((prev) => ({ ...prev, flags: newFlags }));
};
const isDaySchedule = scheduleType === ScheduleFlag.SCHEDULE_DAY;
const isTimerSchedule = scheduleType === ScheduleFlag.SCHEDULE_TIMER;
const isImmediateSchedule = scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE;
const needsTimeField = isDaySchedule || isTimerSchedule;
const dowFlags = getFlagDOWstring(editItem.flags);
const timeFieldValue = needsTimeField
? editItem.time === ''
? DEFAULT_TIME
: editItem.time
: editItem.time === DEFAULT_TIME
? ''
: editItem.time;
const timeFieldLabel = (() => {
if (scheduleType === ScheduleFlag.SCHEDULE_TIMER) return LL.TIMER(1); if (scheduleType === ScheduleFlag.SCHEDULE_TIMER) return LL.TIMER(1);
if (scheduleType === ScheduleFlag.SCHEDULE_CONDITION) return LL.CONDITION(); if (scheduleType === ScheduleFlag.SCHEDULE_CONDITION) return LL.CONDITION();
if (scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE) return LL.ONCHANGE(); if (scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE) return LL.ONCHANGE();
if (scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE) return LL.IMMEDIATE(); if (scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE) return LL.IMMEDIATE();
return LL.TIME(1); return LL.TIME(1);
}, [scheduleType, LL]); })();
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>

View File

@@ -1,4 +1,4 @@
import { useCallback, useContext, useMemo, useRef, useState } from 'react'; import { useContext, useRef, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined'; import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined';
@@ -158,18 +158,16 @@ const Sensors = () => {
} }
); );
const intervalCallback = useCallback(() => { useInterval(() => {
if (!temperatureDialogOpen && !analogDialogOpen) { if (!temperatureDialogOpen && !analogDialogOpen) {
void fetchSensorData(); void fetchSensorData();
} }
}, [temperatureDialogOpen, analogDialogOpen, fetchSensorData]); });
useInterval(intervalCallback);
const temperature_theme = useTheme([common_theme, temperature_theme_config]); const temperature_theme = useTheme([common_theme, temperature_theme_config]);
const analog_theme = useTheme([common_theme, analog_theme_config]); const analog_theme = useTheme([common_theme, analog_theme_config]);
const getSortIcon = useCallback((state: State, sortKey: unknown) => { const getSortIcon = (state: State, sortKey: unknown) => {
if (state.sortKey === sortKey && state.reverse) { if (state.sortKey === sortKey && state.reverse) {
return <KeyboardArrowDownOutlinedIcon />; return <KeyboardArrowDownOutlinedIcon />;
} }
@@ -177,7 +175,7 @@ const Sensors = () => {
return <KeyboardArrowUpOutlinedIcon />; return <KeyboardArrowUpOutlinedIcon />;
} }
return <UnfoldMoreOutlinedIcon />; return <UnfoldMoreOutlinedIcon />;
}, []); };
const analog_sort = useSort( const analog_sort = useSort(
{ nodes: sensorData.as }, { nodes: sensorData.as },
@@ -234,119 +232,104 @@ const Sensors = () => {
useLayoutTitle(LL.SENSORS()); useLayoutTitle(LL.SENSORS());
const formatDurationMin = useCallback( const formatDurationMin = (duration_min: number) => {
(duration_min: number) => { const totalMs = duration_min * MS_PER_MINUTE;
const totalMs = duration_min * MS_PER_MINUTE; const days = Math.trunc(totalMs / MS_PER_DAY);
const days = Math.trunc(totalMs / MS_PER_DAY); const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24;
const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24; const minutes = Math.trunc(totalMs / MS_PER_MINUTE) % 60;
const minutes = Math.trunc(totalMs / MS_PER_MINUTE) % 60;
const parts: string[] = []; const parts: string[] = [];
if (days > 0) { if (days > 0) {
parts.push(LL.NUM_DAYS({ num: days })); parts.push(LL.NUM_DAYS({ num: days }));
} }
if (hours > 0) { if (hours > 0) {
parts.push(LL.NUM_HOURS({ num: hours })); parts.push(LL.NUM_HOURS({ num: hours }));
} }
if (minutes > 0) { if (minutes > 0) {
parts.push(LL.NUM_MINUTES({ num: minutes })); parts.push(LL.NUM_MINUTES({ num: minutes }));
} }
return parts.join(' '); return parts.join(' ');
}, };
[LL]
);
const formatValue = useCallback( const formatValue = (value: unknown, uom: DeviceValueUOM) => {
(value: unknown, uom: DeviceValueUOM) => { if (value === undefined) {
if (value === undefined) { return '';
return ''; }
} if (typeof value !== 'number') {
if (typeof value !== 'number') { return value as string;
return value as string; }
} switch (uom) {
switch (uom) { case DeviceValueUOM.HOURS:
case DeviceValueUOM.HOURS: return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 });
return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 }); case DeviceValueUOM.MINUTES:
case DeviceValueUOM.MINUTES: return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 });
return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 }); case DeviceValueUOM.SECONDS:
case DeviceValueUOM.SECONDS: return LL.NUM_SECONDS({ num: value });
return LL.NUM_SECONDS({ num: value }); case DeviceValueUOM.NONE:
case DeviceValueUOM.NONE: return new Intl.NumberFormat().format(value);
return new Intl.NumberFormat().format(value); case DeviceValueUOM.DEGREES:
case DeviceValueUOM.DEGREES: case DeviceValueUOM.DEGREES_R:
case DeviceValueUOM.DEGREES_R: case DeviceValueUOM.FAHRENHEIT:
case DeviceValueUOM.FAHRENHEIT: return (
return ( new Intl.NumberFormat(undefined, {
new Intl.NumberFormat(undefined, { minimumFractionDigits: 1
minimumFractionDigits: 1 }).format(value) +
}).format(value) + ' ' +
' ' + DeviceValueUOM_s[uom]
DeviceValueUOM_s[uom] );
); default:
default: return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom]; }
} };
},
[formatDurationMin, LL]
);
const updateTemperatureSensor = useCallback( const updateTemperatureSensor = (ts: TemperatureSensor) => {
(ts: TemperatureSensor) => { if (me.admin) {
if (me.admin) { ts.o_n = ts.n;
ts.o_n = ts.n; setSelectedTemperatureSensor(ts);
setSelectedTemperatureSensor(ts); setTemperatureDialogOpen(true);
setTemperatureDialogOpen(true); }
} };
},
[me.admin]
);
const onTemperatureDialogClose = useCallback(() => { const onTemperatureDialogClose = () => {
setTemperatureDialogOpen(false); setTemperatureDialogOpen(false);
void fetchSensorData(); void fetchSensorData();
}, [fetchSensorData]); };
const onTemperatureDialogSave = useCallback( const onTemperatureDialogSave = async (ts: TemperatureSensor) => {
async (ts: TemperatureSensor) => { await sendTemperatureSensor({
await sendTemperatureSensor({ id: ts.id,
id: ts.id, name: ts.n,
name: ts.n, offset: ts.o,
offset: ts.o, is_system: ts.s
is_system: ts.s })
.then(() => {
toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
}) })
.then(() => { .catch(() => {
toast.success(LL.UPDATED_OF(LL.SENSOR(1))); toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1));
}) })
.catch(() => { .finally(() => {
toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1)); setTemperatureDialogOpen(false);
}) setSelectedTemperatureSensor(undefined);
.finally(() => { void fetchSensorData();
setTemperatureDialogOpen(false); });
setSelectedTemperatureSensor(undefined); };
void fetchSensorData();
});
},
[sendTemperatureSensor, LL, fetchSensorData]
);
const updateAnalogSensor = useCallback( const updateAnalogSensor = (as: AnalogSensor) => {
(as: AnalogSensor) => { if (me.admin) {
if (me.admin) { setCreating(false);
setCreating(false); as.o_n = as.n;
as.o_n = as.n; setSelectedAnalogSensor(as);
setSelectedAnalogSensor(as); setAnalogDialogOpen(true);
setAnalogDialogOpen(true); }
} };
},
[me.admin]
);
const onAnalogDialogClose = useCallback(() => { const onAnalogDialogClose = () => {
setAnalogDialogOpen(false); setAnalogDialogOpen(false);
void fetchSensorData(); void fetchSensorData();
}, [fetchSensorData]); };
const addAnalogSensor = useCallback(() => { const addAnalogSensor = () => {
if (firstAvailableGPIO.current === undefined) { if (firstAvailableGPIO.current === undefined) {
toast.error(LL.NO_GPIO()); toast.error(LL.NO_GPIO());
return; return;
@@ -366,194 +349,167 @@ const Sensors = () => {
o_n: '' o_n: ''
}); });
setAnalogDialogOpen(true); setAnalogDialogOpen(true);
}, []); };
const onAnalogDialogSave = useCallback( const onAnalogDialogSave = async (as: AnalogSensor) => {
async (as: AnalogSensor) => { await sendAnalogSensor({
await sendAnalogSensor({ id: as.id,
id: as.id, gpio: as.g,
gpio: as.g, name: as.n,
name: as.n, offset: as.o,
offset: as.o, factor: as.f,
factor: as.f, uom: as.u,
uom: as.u, type: as.t,
type: as.t, deleted: as.d,
deleted: as.d, is_system: as.s
is_system: as.s })
.then(() => {
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
}) })
.then(() => { .catch(() => {
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2))); toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1));
}) })
.catch(() => { .finally(() => {
toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1)); setAnalogDialogOpen(false);
}) setSelectedAnalogSensor(undefined);
.finally(() => { void fetchSensorData();
setAnalogDialogOpen(false); });
setSelectedAnalogSensor(undefined); };
void fetchSensorData();
}); const RenderAnalogSensors = (
}, <Table
[sendAnalogSensor, LL, fetchSensorData] data={{ nodes: sensorData.as }}
theme={analog_theme}
sort={analog_sort}
layout={{ custom: true }}
>
{(tableList: AnalogSensor[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'GPIO')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })}
>
GPIO
</Button>
</HeaderCell>
<HeaderCell resize>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'NAME')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
>
{LL.NAME(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'TYPE')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
>
{LL.TYPE(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE_END}
endIcon={getSortIcon(analog_sort.state, 'VALUE')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })}
>
{LL.VALUE(0)}
</Button>
</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((as: AnalogSensor) => (
<Row
style={{ color: as.s ? 'grey' : 'inherit' }}
key={as.id}
item={as}
onClick={() => updateAnalogSensor(as)}
>
<Cell stiff>{as.g}</Cell>
<Cell>{as.n}</Cell>
<Cell stiff>{AnalogTypeNames[as.t - 1]} </Cell>
{(as.t === AnalogType.DIGITAL_OUT &&
as.g !== GPIO_25 &&
as.g !== GPIO_26) ||
as.t === AnalogType.DIGITAL_IN ||
as.t === AnalogType.PULSE ? (
<Cell stiff>{as.v ? LL.ON() : LL.OFF()}</Cell>
) : (
<Cell stiff>{formatValue(as.v, as.u)}</Cell>
)}
</Row>
))}
</Body>
</>
)}
</Table>
); );
const RenderAnalogSensors = useMemo( const RenderTemperatureSensors = (
() => ( <Table
<Table data={{ nodes: sensorData.ts }}
data={{ nodes: sensorData.as }} theme={temperature_theme}
theme={analog_theme} sort={temperature_sort}
sort={analog_sort} layout={{ custom: true }}
layout={{ custom: true }} >
> {(tableList: TemperatureSensor[]) => (
{(tableList: AnalogSensor[]) => ( <>
<> <Header>
<Header> <HeaderRow>
<HeaderRow> <HeaderCell resize>
<HeaderCell stiff> <Button
<Button fullWidth
fullWidth style={HEADER_BUTTON_STYLE}
style={HEADER_BUTTON_STYLE} endIcon={getSortIcon(temperature_sort.state, 'NAME')}
endIcon={getSortIcon(analog_sort.state, 'GPIO')} onClick={() =>
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })} temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
> }
GPIO
</Button>
</HeaderCell>
<HeaderCell resize>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'NAME')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
>
{LL.NAME(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'TYPE')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
>
{LL.TYPE(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE_END}
endIcon={getSortIcon(analog_sort.state, 'VALUE')}
onClick={() =>
analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })
}
>
{LL.VALUE(0)}
</Button>
</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((as: AnalogSensor) => (
<Row
style={{ color: as.s ? 'grey' : 'inherit' }}
key={as.id}
item={as}
onClick={() => updateAnalogSensor(as)}
> >
<Cell stiff>{as.g}</Cell> {LL.NAME(0)}
<Cell>{as.n}</Cell> </Button>
<Cell stiff>{AnalogTypeNames[as.t - 1]} </Cell> </HeaderCell>
{(as.t === AnalogType.DIGITAL_OUT && <HeaderCell stiff>
as.g !== GPIO_25 && <Button
as.g !== GPIO_26) || fullWidth
as.t === AnalogType.DIGITAL_IN || style={HEADER_BUTTON_STYLE_END}
as.t === AnalogType.PULSE ? ( endIcon={getSortIcon(temperature_sort.state, 'VALUE')}
<Cell stiff>{as.v ? LL.ON() : LL.OFF()}</Cell> onClick={() =>
) : ( temperature_sort.fns.onToggleSort({ sortKey: 'VALUE' })
<Cell stiff>{formatValue(as.v, as.u)}</Cell> }
)}
</Row>
))}
</Body>
</>
)}
</Table>
),
[
analog_sort,
analog_theme,
getSortIcon,
sensorData.as,
LL,
updateAnalogSensor,
formatValue
]
);
const RenderTemperatureSensors = useMemo(
() => (
<Table
data={{ nodes: sensorData.ts }}
theme={temperature_theme}
sort={temperature_sort}
layout={{ custom: true }}
>
{(tableList: TemperatureSensor[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell resize>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(temperature_sort.state, 'NAME')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
}
>
{LL.NAME(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE_END}
endIcon={getSortIcon(temperature_sort.state, 'VALUE')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'VALUE' })
}
>
{LL.VALUE(0)}
</Button>
</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((ts: TemperatureSensor) => (
<Row
style={{ color: ts.s ? 'grey' : 'inherit' }}
key={ts.id}
item={ts}
onClick={() => updateTemperatureSensor(ts)}
> >
<Cell>{ts.n}</Cell> {LL.VALUE(0)}
<Cell>{formatValue(ts.t, ts.u)}</Cell> </Button>
</Row> </HeaderCell>
))} </HeaderRow>
</Body> </Header>
</> <Body>
)} {tableList.map((ts: TemperatureSensor) => (
</Table> <Row
), style={{ color: ts.s ? 'grey' : 'inherit' }}
[ key={ts.id}
temperature_sort, item={ts}
temperature_theme, onClick={() => updateTemperatureSensor(ts)}
getSortIcon, >
sensorData.ts, <Cell>{ts.n}</Cell>
LL, <Cell>{formatValue(ts.t, ts.u)}</Cell>
updateTemperatureSensor, </Row>
formatValue ))}
] </Body>
</>
)}
</Table>
); );
return ( return (

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done'; import DoneIcon from '@mui/icons-material/Done';
@@ -53,84 +53,54 @@ const SensorsAnalogDialog = ({
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem); const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem);
const updateFormValue = useMemo( const updateFormValue = updateValue((updater) =>
() => setEditItem(
updateValue((updater) => (prev) =>
setEditItem( updater(
(prev) => prev as unknown as Record<string, unknown>
updater( ) as unknown as AnalogSensor
prev as unknown as Record<string, unknown> )
) as unknown as AnalogSensor
)
),
[setEditItem]
); );
// Memoize helper functions to check sensor type conditions const isCounterOrRate =
const isCounterOrRate = useMemo( editItem.t === AnalogType.COUNTER ||
() => editItem.t === AnalogType.RATE ||
editItem.t === AnalogType.COUNTER || (editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2);
editItem.t === AnalogType.RATE || const isCounter =
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2), editItem.t === AnalogType.COUNTER ||
[editItem.t] (editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2);
); const isFreqType =
const isCounter = useMemo( editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2;
() => const isPWM =
editItem.t === AnalogType.COUNTER || editItem.t === AnalogType.PWM_0 ||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2), editItem.t === AnalogType.PWM_1 ||
[editItem.t] editItem.t === AnalogType.PWM_2;
); const isDACOutGPIO =
const isFreqType = useMemo( editItem.t === AnalogType.DIGITAL_OUT &&
() => editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2, (editItem.g === 25 || editItem.g === 26);
[editItem.t] const isDigitalOutGPIO =
); editItem.t === AnalogType.DIGITAL_OUT && editItem.g !== 25 && editItem.g !== 26;
const isPWM = useMemo(
() =>
editItem.t === AnalogType.PWM_0 ||
editItem.t === AnalogType.PWM_1 ||
editItem.t === AnalogType.PWM_2,
[editItem.t]
);
const isDACOutGPIO = useMemo(
() =>
editItem.t === AnalogType.DIGITAL_OUT &&
(editItem.g === 25 || editItem.g === 26),
[editItem.t, editItem.g]
);
const isDigitalOutGPIO = useMemo(
() =>
editItem.t === AnalogType.DIGITAL_OUT &&
editItem.g !== 25 &&
editItem.g !== 26,
[editItem.t, editItem.g]
);
// Memoize menu items to avoid recreation on each render const analogTypeMenuItems = AnalogTypeNames.map((val, i) => ({
const analogTypeMenuItems = useMemo( name: val,
() => value: i + 1
AnalogTypeNames.map((val, i) => ({ name: val, value: i + 1 })) }))
.sort((a, b) => a.name.localeCompare(b.name)) .sort((a, b) => a.name.localeCompare(b.name))
.map(({ name, value }) => ( .map(({ name, value }) => (
<MenuItem <MenuItem
key={name} key={name}
value={value} value={value}
disabled={disabledTypeList?.includes(value)} disabled={disabledTypeList?.includes(value)}
> >
{name} {name}
</MenuItem> </MenuItem>
)), ));
[disabledTypeList]
);
const uomMenuItems = useMemo( const uomMenuItems = DeviceValueUOM_s.map((val, i) => (
() => <MenuItem key={val} value={i}>
DeviceValueUOM_s.map((val, i) => ( {val}
<MenuItem key={val} value={i}> </MenuItem>
{val} ));
</MenuItem>
)),
[]
);
const analogGPIOMenuItems = () => const analogGPIOMenuItems = () =>
// add selectedItem.g to the list // add selectedItem.g to the list
@@ -157,16 +127,16 @@ const SensorsAnalogDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = useCallback( const handleClose = (
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => { _event: React.SyntheticEvent,
if (reason !== 'backdropClick') { reason: 'backdropClick' | 'escapeKeyDown'
onClose(); ) => {
} if (reason !== 'backdropClick') {
}, onClose();
[onClose] }
); };
const save = useCallback(async () => { const save = async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, editItem); await validate(validator, editItem);
@@ -174,17 +144,13 @@ const SensorsAnalogDialog = ({
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}, [validator, editItem, onSave]); };
const remove = useCallback(() => { const remove = () => {
onSave({ ...editItem, d: true }); onSave({ ...editItem, d: true });
}, [editItem, onSave]); };
const dialogTitle = useMemo( const dialogTitle = `${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`;
() =>
`${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`,
[creating, LL]
);
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done'; import DoneIcon from '@mui/icons-material/Done';
@@ -50,16 +50,12 @@ const SensorsTemperatureDialog = ({
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [editItem, setEditItem] = useState<TemperatureSensor>(selectedItem); const [editItem, setEditItem] = useState<TemperatureSensor>(selectedItem);
const updateFormValue = useMemo( const updateFormValue = updateValue(
() => setEditItem as unknown as (
updateValue( updater: (
setEditItem as unknown as ( prevState: Readonly<Record<string, unknown>>
updater: ( ) => Record<string, unknown>
prevState: Readonly<Record<string, unknown>> ) => void
) => Record<string, unknown>
) => void
),
[setEditItem]
); );
useEffect(() => { useEffect(() => {
@@ -69,16 +65,13 @@ const SensorsTemperatureDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = useCallback( const handleClose = (_event: React.SyntheticEvent, reason?: string) => {
(_event: React.SyntheticEvent, reason?: string) => { if (reason !== 'backdropClick') {
if (reason !== 'backdropClick') { onClose();
onClose(); }
} };
},
[onClose]
);
const save = useCallback(async () => { const save = async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, editItem); await validate(validator, editItem);
@@ -86,29 +79,11 @@ const SensorsTemperatureDialog = ({
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}, [validator, editItem, onSave]); };
const dialogTitle = useMemo(() => `${LL.EDIT()} ${LL.TEMP_SENSOR()}`, [LL]);
const offsetValue = useMemo(() => numberValue(editItem.o), [editItem.o]);
const slotProps = useMemo(
() => ({
input: {
startAdornment: <InputAdornment position="start">{TEMP_UNIT}</InputAdornment>
},
htmlInput: {
min: OFFSET_MIN,
max: OFFSET_MAX,
step: OFFSET_STEP
}
}),
[]
);
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>{dialogTitle}</DialogTitle> <DialogTitle>{`${LL.EDIT()} ${LL.TEMP_SENSOR()}`}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Typography sx={{ mb: 2 }} color="warning" variant="body2"> <Typography sx={{ mb: 2 }} color="warning" variant="body2">
{LL.ID_OF(LL.SENSOR(0))}: {editItem.id} {LL.ID_OF(LL.SENSOR(0))}: {editItem.id}
@@ -128,12 +103,23 @@ const SensorsTemperatureDialog = ({
<TextField <TextField
name="o" name="o"
label={LL.OFFSET()} label={LL.OFFSET()}
value={offsetValue} value={numberValue(editItem.o)}
sx={{ width: '11ch' }} sx={{ width: '11ch' }}
type="number" type="number"
variant="outlined" variant="outlined"
onChange={updateFormValue} onChange={updateFormValue}
slotProps={slotProps} slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">{TEMP_UNIT}</InputAdornment>
)
},
htmlInput: {
min: OFFSET_MIN,
max: OFFSET_MAX,
step: OFFSET_STEP
}
}}
/> />
</Grid> </Grid>
</Grid> </Grid>

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useContext } from 'react'; import { memo, useContext } from 'react';
import PersonIcon from '@mui/icons-material/Person'; import PersonIcon from '@mui/icons-material/Person';
import { import {
@@ -23,9 +23,9 @@ const UserProfileComponent = () => {
useLayoutTitle(LL.USER_PROFILE()); useLayoutTitle(LL.USER_PROFILE());
const handleSignOut = useCallback(() => { const handleSignOut = () => {
signOut(true); signOut(true);
}, [signOut]); };
return ( return (
<SectionContent> <SectionContent>

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react'; import { useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
@@ -62,22 +62,16 @@ const APSettings = () => {
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = useMemo( const updateFormValue = updateValueDirty(
() => origData as unknown as Record<string, unknown>,
updateValueDirty( dirtyFlags,
origData as unknown as Record<string, unknown>, setDirtyFlags,
dirtyFlags, updateDataValue as (value: unknown) => void
setDirtyFlags,
updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
); );
// Memoize AP enabled state const apEnabled = data ? isAPEnabled(data) : false;
const apEnabled = useMemo(() => (data ? isAPEnabled(data) : false), [data]);
// Memoize validation and submit handler const validateAndSubmit = async () => {
const validateAndSubmit = useCallback(async () => {
if (!data) return; if (!data) return;
try { try {
@@ -87,7 +81,7 @@ const APSettings = () => {
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}, [data, saveData]); };
const content = () => { const content = () => {
if (!data) { if (!data) {

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react'; import { useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -107,49 +107,36 @@ const ApplicationSettings = () => {
}); });
}); });
// Memoized input props to prevent recreation on every render const SecondsInputProps = {
const SecondsInputProps = useMemo( endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
() => ({ };
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}),
[LL]
);
const MinutesInputProps = useMemo( const MinutesInputProps = {
() => ({ endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment> };
}),
[LL]
);
const HoursInputProps = useMemo( const HoursInputProps = {
() => ({ endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment> };
}),
[LL]
);
const doRestart = useCallback(async () => { const doRestart = async () => {
setRestarting(true); setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => { (error: Error) => {
toast.error(error.message); toast.error(error.message);
} }
); );
}, [sendAPI]); };
const updateBoardProfile = useCallback( const updateBoardProfile = async (board_profile: string) => {
async (board_profile: string) => { await readBoardProfile(board_profile).catch((error: Error) => {
await readBoardProfile(board_profile).catch((error: Error) => { toast.error(error.message);
toast.error(error.message); });
}); };
},
[readBoardProfile]
);
useLayoutTitle(LL.APPLICATION()); useLayoutTitle(LL.APPLICATION());
const validateAndSubmit = useCallback(async () => { const validateAndSubmit = async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(createSettingsValidator(data), data); await validate(createSettingsValidator(data), data);
@@ -158,31 +145,27 @@ const ApplicationSettings = () => {
} finally { } finally {
await saveData(); await saveData();
} }
}, [data, saveData]); };
const changeBoardProfile = useCallback( const changeBoardProfile = (event: React.ChangeEvent<HTMLInputElement>) => {
(event: React.ChangeEvent<HTMLInputElement>) => { const boardProfile = event.target.value;
const boardProfile = event.target.value; updateFormValue(event);
updateFormValue(event); if (boardProfile === 'CUSTOM') {
if (boardProfile === 'CUSTOM') { updateDataValue({
updateDataValue({ ...data,
...data, board_profile: boardProfile
board_profile: boardProfile });
}); } else {
} else { void updateBoardProfile(boardProfile);
void updateBoardProfile(boardProfile); }
} };
},
[data, updateBoardProfile, updateFormValue, updateDataValue]
);
const restart = useCallback(async () => { const restart = async () => {
await validateAndSubmit(); await validateAndSubmit();
await doRestart(); await doRestart();
}, [validateAndSubmit, doRestart]); };
// Memoize board profile select items to prevent recreation const boardProfileItems = boardProfileSelectItems();
const boardProfileItems = useMemo(() => boardProfileSelectItems(), []);
const content = () => { const content = () => {
if (!data || !hardwareData) { if (!data || !hardwareData) {

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react'; import { useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -57,7 +57,7 @@ const DownloadUpload = () => {
const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus); const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus);
const doRestart = useCallback(async () => { const doRestart = async () => {
setRestarting(true); setRestarting(true);
try { try {
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }); await sendAPI({ device: 'system', cmd: 'restart', id: 0 });
@@ -65,16 +65,33 @@ const DownloadUpload = () => {
toast.error((error as Error).message); toast.error((error as Error).message);
setRestarting(false); setRestarting(false);
} }
}, [sendAPI]); };
useLayoutTitle(LL.DOWNLOAD_UPLOAD()); useLayoutTitle(LL.DOWNLOAD_UPLOAD());
const handleCloseBackupDialog = useCallback(() => { const handleCloseBackupDialog = () => {
setConfirmBackup(false); setConfirmBackup(false);
}, []); };
const renderBackupDialog = useMemo( const handleDownload = (type: string) => () => {
() => ( void sendExportData(type);
setConfirmBackup(false);
};
if (restarting) {
return <SystemMonitor />;
}
if (!data) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
return (
<SectionContent>
<Dialog <Dialog
sx={dialogStyle} sx={dialogStyle}
open={confirmBackup} open={confirmBackup}
@@ -98,40 +115,13 @@ const DownloadUpload = () => {
<Button <Button
startIcon={<DownloadIcon />} startIcon={<DownloadIcon />}
variant="outlined" variant="outlined"
onClick={() => handleDownload('systembackup')()} onClick={handleDownload('systembackup')}
color="primary" color="primary"
> >
{LL.DOWNLOAD(0)} {LL.DOWNLOAD(0)}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
),
[confirmBackup, handleCloseBackupDialog, LL]
);
const handleDownload = useCallback(
(type: string) => () => {
void sendExportData(type);
setConfirmBackup(false);
},
[sendExportData]
);
if (restarting) {
return <SystemMonitor />;
}
if (!data) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
return (
<SectionContent>
{renderBackupDialog}
<Typography sx={{ pb: 2 }} variant="h6" color="primary"> <Typography sx={{ pb: 2 }} variant="h6" color="primary">
{LL.DOWNLOAD(0)} {LL.DOWNLOAD(0)}

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react'; import { useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -57,7 +57,7 @@ const MqttSettings = () => {
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const sendResetMQTT = useCallback(() => { const sendResetMQTT = () => {
void callAction({ action: 'resetMQTT' }) void callAction({ action: 'resetMQTT' })
.then(() => { .then(() => {
toast.success('MQTT ' + LL.REFRESH() + ' successful'); toast.success('MQTT ' + LL.REFRESH() + ' successful');
@@ -65,29 +65,20 @@ const MqttSettings = () => {
.catch((error) => { .catch((error) => {
toast.error(String(error.error?.message || 'An error occurred')); toast.error(String(error.error?.message || 'An error occurred'));
}); });
}, []); };
const updateFormValue = useMemo( const updateFormValue = updateValueDirty(
() => origData as unknown as Record<string, unknown>,
updateValueDirty( dirtyFlags,
origData as unknown as Record<string, unknown>, setDirtyFlags,
dirtyFlags, updateDataValue as (value: unknown) => void
setDirtyFlags,
updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
); );
const SecondsInputProps = useMemo( const SecondsInputProps = {
() => ({ endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment> };
}),
[LL]
);
const emptyFieldErrors = useMemo(() => ({}), []); const validateAndSubmit = async () => {
const validateAndSubmit = useCallback(async () => {
if (!data) return; if (!data) return;
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
@@ -96,25 +87,22 @@ const MqttSettings = () => {
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}, [data, saveData]); };
const publishIntervalFields = useMemo( const publishIntervalFields = [
() => [ { name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true },
{ name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true }, { name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false },
{ name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false }, {
{ name: 'publish_time_thermostat',
name: 'publish_time_thermostat', label: LL.MQTT_INT_THERMOSTATS(),
label: LL.MQTT_INT_THERMOSTATS(), validated: false
validated: false },
}, { name: 'publish_time_solar', label: LL.MQTT_INT_SOLAR(), validated: false },
{ name: 'publish_time_solar', label: LL.MQTT_INT_SOLAR(), validated: false }, { name: 'publish_time_mixer', label: LL.MQTT_INT_MIXER(), validated: false },
{ name: 'publish_time_mixer', label: LL.MQTT_INT_MIXER(), validated: false }, { name: 'publish_time_water', label: LL.MQTT_INT_WATER(), validated: false },
{ name: 'publish_time_water', label: LL.MQTT_INT_WATER(), validated: false }, { name: 'publish_time_sensor', label: LL.SENSORS(), validated: false },
{ name: 'publish_time_sensor', label: LL.SENSORS(), validated: false }, { name: 'publish_time_other', label: LL.DEFAULT(0), validated: false }
{ name: 'publish_time_other', label: LL.DEFAULT(0), validated: false } ];
],
[LL]
);
if (!data) { if (!data) {
return ( return (
@@ -154,7 +142,7 @@ const MqttSettings = () => {
<Grid container spacing={2} rowSpacing={0}> <Grid container spacing={2} rowSpacing={0}>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? emptyFieldErrors} fieldErrors={fieldErrors ?? {}}
name="host" name="host"
label={LL.ADDRESS_OF(LL.BROKER())} label={LL.ADDRESS_OF(LL.BROKER())}
multiline multiline
@@ -166,7 +154,7 @@ const MqttSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? emptyFieldErrors} fieldErrors={fieldErrors ?? {}}
name="port" name="port"
label="Port" label="Port"
variant="outlined" variant="outlined"
@@ -178,7 +166,7 @@ const MqttSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? emptyFieldErrors} fieldErrors={fieldErrors ?? {}}
name="base" name="base"
label={LL.BASE_TOPIC()} label={LL.BASE_TOPIC()}
variant="outlined" variant="outlined"
@@ -219,7 +207,7 @@ const MqttSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? emptyFieldErrors} fieldErrors={fieldErrors ?? {}}
name="keep_alive" name="keep_alive"
label="Keep Alive" label="Keep Alive"
slotProps={{ slotProps={{
@@ -438,7 +426,7 @@ const MqttSettings = () => {
<Grid key={field.name}> <Grid key={field.name}>
{field.validated ? ( {field.validated ? (
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors ?? emptyFieldErrors} fieldErrors={fieldErrors ?? {}}
name={field.name} name={field.name}
label={field.label} label={field.label}
slotProps={{ slotProps={{

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react'; import { useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
@@ -61,14 +61,11 @@ const NTPSettings = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
useLayoutTitle('NTP'); useLayoutTitle('NTP');
// Memoized timezone select items for better performance
const timeZoneItems = useTimeZoneSelectItems(); const timeZoneItems = useTimeZoneSelectItems();
// Memoized selected timezone value const selectedTzValue = data
const selectedTzValue = useMemo( ? selectedTimeZone(data.tz_label, data.tz_format)
() => (data ? selectedTimeZone(data.tz_label, data.tz_format) : undefined), : undefined;
[data?.tz_label, data?.tz_format]
);
const [localTime, setLocalTime] = useState<string>(''); const [localTime, setLocalTime] = useState<string>('');
const [settingTime, setSettingTime] = useState<boolean>(false); const [settingTime, setSettingTime] = useState<boolean>(false);
@@ -82,32 +79,22 @@ const NTPSettings = () => {
} }
); );
// Memoize updateFormValue to prevent recreation on every render const updateFormValue = updateValueDirty(
const updateFormValue = useMemo( origData as unknown as Record<string, unknown>,
() => dirtyFlags,
updateValueDirty( setDirtyFlags,
origData as unknown as Record<string, unknown>, updateDataValue as (value: unknown) => void
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
); );
// Memoize updateLocalTime handler const updateLocalTime = (event: React.ChangeEvent<HTMLInputElement>) =>
const updateLocalTime = useCallback( setLocalTime(event.target.value);
(event: React.ChangeEvent<HTMLInputElement>) => setLocalTime(event.target.value),
[]
);
// Memoize openSetTime handler const openSetTime = () => {
const openSetTime = useCallback(() => {
setLocalTime(formatLocalDateTime(new Date())); setLocalTime(formatLocalDateTime(new Date()));
setSettingTime(true); setSettingTime(true);
}, []); };
// Memoize configureTime handler const configureTime = async () => {
const configureTime = useCallback(async () => {
setProcessing(true); setProcessing(true);
try { try {
@@ -120,13 +107,11 @@ const NTPSettings = () => {
} finally { } finally {
setProcessing(false); setProcessing(false);
} }
}, [localTime, updateTime, LL, loadData]); };
// Memoize close dialog handler const handleCloseSetTime = () => setSettingTime(false);
const handleCloseSetTime = useCallback(() => setSettingTime(false), []);
// Memoize validate and submit handler const validateAndSubmit = async () => {
const validateAndSubmit = useCallback(async () => {
if (!data) return; if (!data) return;
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
@@ -135,23 +120,18 @@ const NTPSettings = () => {
} catch (error) { } catch (error) {
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
}, [data, saveData]); };
// Memoize timezone change handler const changeTimeZone = (event: React.ChangeEvent<HTMLInputElement>) => {
const changeTimeZone = useCallback( void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
(event: React.ChangeEvent<HTMLInputElement>) => { ...settings,
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({ tz_label: event.target.value,
...settings, tz_format: TIME_ZONES[event.target.value]
tz_label: event.target.value, }));
tz_format: TIME_ZONES[event.target.value] updateFormValue(event);
})); };
updateFormValue(event);
},
[updateFormValue]
);
// Memoize render content to prevent unnecessary re-renders const renderContent = () => {
const renderContent = useMemo(() => {
if (!data) { if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />; return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
} }
@@ -236,26 +216,12 @@ const NTPSettings = () => {
)} )}
</> </>
); );
}, [ };
data,
errorMessage,
loadData,
updateFormValue,
fieldErrors,
selectedTzValue,
changeTimeZone,
timeZoneItems,
dirtyFlags,
openSetTime,
saving,
validateAndSubmit,
LL
]);
return ( return (
<SectionContent> <SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null} {blocker ? <BlockNavigation blocker={blocker} /> : null}
{renderContent} {renderContent()}
<Dialog sx={dialogStyle} open={settingTime} onClose={handleCloseSetTime}> <Dialog sx={dialogStyle} open={settingTime} onClose={handleCloseSetTime}>
<DialogTitle>{LL.SET_TIME(1)}</DialogTitle> <DialogTitle>{LL.SET_TIME(1)}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>

View File

@@ -1,190 +1,108 @@
import { useCallback, useMemo, useState } from 'react'; import { useContext } from 'react';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CancelIcon from '@mui/icons-material/Cancel'; import BuildIcon from '@mui/icons-material/Build';
import DeviceHubIcon from '@mui/icons-material/DeviceHub'; import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import ImportExportIcon from '@mui/icons-material/ImportExport'; import ImportExportIcon from '@mui/icons-material/ImportExport';
import LockIcon from '@mui/icons-material/Lock'; import LockIcon from '@mui/icons-material/Lock';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet'; import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna'; import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import TuneIcon from '@mui/icons-material/Tune'; import TuneIcon from '@mui/icons-material/Tune';
import ViewModuleIcon from '@mui/icons-material/ViewModule'; import ViewModuleIcon from '@mui/icons-material/ViewModule';
import { import { List } from '@mui/material';
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
List
} from '@mui/material';
import { API } from 'api/app';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import type { APIcall } from 'app/main/types';
import { SectionContent, useLayoutTitle } from 'components'; import { SectionContent, useLayoutTitle } from 'components';
import ListMenuItem from 'components/layout/ListMenuItem'; import ListMenuItem from 'components/layout/ListMenuItem';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import SystemMonitor from '../status/SystemMonitor';
const Settings = () => { const Settings = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const { versions } = useContext(AuthenticatedContext);
useLayoutTitle(LL.SETTINGS(0)); useLayoutTitle(LL.SETTINGS(0));
const [confirmFactoryReset, setConfirmFactoryReset] = useState(false); const upgradeAvailable = versions?.current?.upgradeable ?? false;
const [restarting, setRestarting] = useState<boolean>(); const firmwareText = versions?.current?.version
? `v${versions.current.version}${upgradeAvailable ? ` (${LL.UPDATE_AVAILABLE()})` : ''}`
: '';
const { send: sendAPI } = useRequest((data: APIcall) => API(data), { return (
immediate: false <SectionContent>
}); <List>
<ListMenuItem
icon={BuildIcon}
bgcolor="#72caf9"
label="EMS-ESP Firmware"
text={firmwareText}
to="/settings/version"
badge={upgradeAvailable}
/>
const doFormat = useCallback(async () => { <ListMenuItem
await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => { icon={TuneIcon}
setRestarting(true); bgcolor="#134ba2"
setConfirmFactoryReset(false); label={LL.APPLICATION()}
}); text={LL.APPLICATION_SETTINGS_1()}
}, [sendAPI]); to="application"
/>
const handleFactoryResetClose = useCallback(() => { <ListMenuItem
setConfirmFactoryReset(false); icon={SettingsEthernetIcon}
}, []); bgcolor="#40828f"
label={LL.NETWORK(0)}
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))}
to="network"
/>
const handleFactoryResetClick = useCallback(() => { <ListMenuItem
setConfirmFactoryReset(true); icon={SettingsInputAntennaIcon}
}, []); bgcolor="#5f9a5f"
label={LL.ACCESS_POINT(0)}
text={LL.CONFIGURE(LL.ACCESS_POINT(1))}
to="ap"
/>
const content = useMemo(() => { <ListMenuItem
return ( icon={AccessTimeIcon}
<> bgcolor="#c5572c"
<List> label="NTP"
<ListMenuItem text={LL.CONFIGURE(LL.LOCAL_TIME(1))}
icon={TuneIcon} to="ntp"
bgcolor="#134ba2" />
label={LL.APPLICATION()}
text={LL.APPLICATION_SETTINGS_1()}
to="application"
/>
<ListMenuItem <ListMenuItem
icon={SettingsEthernetIcon} icon={DeviceHubIcon}
bgcolor="#40828f" bgcolor="#68374d"
label={LL.NETWORK(0)} label="MQTT"
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))} text={LL.CONFIGURE('MQTT')}
to="network" to="mqtt"
/> />
<ListMenuItem <ListMenuItem
icon={SettingsInputAntennaIcon} icon={LockIcon}
bgcolor="#5f9a5f" label={LL.SECURITY(0)}
label={LL.ACCESS_POINT(0)} text={LL.SECURITY_1()}
text={LL.CONFIGURE(LL.ACCESS_POINT(1))} to="security"
to="ap" />
/>
<ListMenuItem <ListMenuItem
icon={AccessTimeIcon} icon={ViewModuleIcon}
bgcolor="#c5572c" bgcolor="#efc34b"
label="NTP" label={LL.MODULES()}
text={LL.CONFIGURE(LL.LOCAL_TIME(1))} text={LL.MODULES_1()}
to="ntp" to="modules"
/> />
<ListMenuItem <ListMenuItem
icon={DeviceHubIcon} icon={ImportExportIcon}
bgcolor="#68374d" bgcolor="#5d89f7"
label="MQTT" label={LL.DOWNLOAD_UPLOAD()}
text={LL.CONFIGURE('MQTT')} text={LL.DOWNLOAD_UPLOAD_1()}
to="mqtt" to="downloadUpload"
/> />
</List>
<ListMenuItem </SectionContent>
icon={LockIcon} );
label={LL.SECURITY(0)}
text={LL.SECURITY_1()}
to="security"
/>
<ListMenuItem
icon={ViewModuleIcon}
bgcolor="#efc34b"
label={LL.MODULES()}
text={LL.MODULES_1()}
to="modules"
/>
<ListMenuItem
icon={ImportExportIcon}
bgcolor="#5d89f7"
label={LL.DOWNLOAD_UPLOAD()}
text={LL.DOWNLOAD_UPLOAD_1()}
to="downloadUpload"
/>
</List>
<Dialog
sx={dialogStyle}
open={confirmFactoryReset}
onClose={handleFactoryResetClose}
>
<DialogTitle>{LL.FACTORY_RESET()}</DialogTitle>
<DialogContent dividers>{LL.SYSTEM_FACTORY_TEXT_DIALOG()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={handleFactoryResetClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={doFormat}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
</DialogActions>
</Dialog>
<Divider />
<Box
sx={{
mt: 2,
display: 'flex',
justifyContent: 'flex-end',
flexWrap: 'nowrap',
whiteSpace: 'nowrap'
}}
>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={handleFactoryResetClick}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
</Box>
</>
);
}, [
LL,
handleFactoryResetClick,
handleFactoryResetClose,
doFormat,
confirmFactoryReset,
restarting
]);
return restarting ? <SystemMonitor /> : <SectionContent>{content}</SectionContent>;
}; };
export default Settings; export default Settings;

View File

@@ -1,5 +1,3 @@
import { useMemo } from 'react';
import { MenuItem } from '@mui/material'; import { MenuItem } from '@mui/material';
export const TIME_ZONES: Record<string, string> = { export const TIME_ZONES: Record<string, string> = {
@@ -472,26 +470,16 @@ export function selectedTimeZone(label: string, format: string) {
return TIME_ZONES[label] === format ? label : undefined; return TIME_ZONES[label] === format ? label : undefined;
} }
// Memoized version for use in components
export function useTimeZoneSelectItems() {
return useMemo(
() =>
TIME_ZONE_LABELS.map((label) => (
<MenuItem key={label} value={label}>
{label}
</MenuItem>
)),
[]
);
}
// Fallback export for backward compatibility - now memoized
const precomputedTimeZoneItems = TIME_ZONE_LABELS.map((label) => ( const precomputedTimeZoneItems = TIME_ZONE_LABELS.map((label) => (
<MenuItem key={label} value={label}> <MenuItem key={label} value={label}>
{label} {label}
</MenuItem> </MenuItem>
)); ));
export function useTimeZoneSelectItems() {
return precomputedTimeZoneItems;
}
export function timeZoneSelectItems() { export function timeZoneSelectItems() {
return precomputedTimeZoneItems; return precomputedTimeZoneItems;
} }

View File

@@ -0,0 +1,959 @@
import { memo, useContext, useMemo, useState } from 'react';
import { Link } from 'react-router';
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 PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
Grid,
IconButton,
Table,
TableBody,
TableCell,
TableRow,
Typography
} from '@mui/material';
import * as SystemApi from 'api/system';
import { API, callAction } from 'api/app';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import type { APIcall } from 'app/main/types';
import SystemMonitor from 'app/status/SystemMonitor';
import {
FormLoader,
SectionContent,
SingleUpload,
useLayoutTitle
} from 'components';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import type { TranslationFunctions } from 'i18n/i18n-types';
import type { VersionInfo } from 'types';
import { prettyDateTime } from 'utils/time';
// 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';
// Types for better type safety
interface PartitionData {
partition: string;
version: string;
install_date?: string;
size: number;
}
interface VersionData {
emsesp_version: string;
arduino_version: string;
esp_platform: string;
flash_chip_size: number;
psram: boolean;
build_flags?: string;
partition: string;
partitions: PartitionData[];
developer_mode: boolean;
}
// Memoized components for better performance
const VersionInfoDialog = memo(
({
showVersionInfo,
latestVersion,
latestDevVersion,
partitionVersion,
partition,
currentPartition,
size,
locale,
LL,
onClose
}: {
showVersionInfo: number;
latestVersion: VersionInfo | undefined;
latestDevVersion: VersionInfo | undefined;
partitionVersion: VersionInfo | undefined;
partition: string;
currentPartition: string;
size: number;
locale: string;
LL: TranslationFunctions;
onClose: () => void;
}) => {
if (showVersionInfo === 0) return null;
const isStable = showVersionInfo === 1;
const isDev = showVersionInfo === 2;
const isPartition = showVersionInfo === 3;
const version = isStable
? latestVersion
: isDev
? latestDevVersion
: partitionVersion;
const relNotesUrl = isStable
? STABLE_RELNOTES_URL
: isDev
? DEV_RELNOTES_URL
: '';
return (
<Dialog sx={dialogStyle} open={showVersionInfo !== 0} onClose={onClose}>
<DialogTitle>{LL.FIRMWARE_VERSION_INFO()}</DialogTitle>
<DialogContent dividers>
<Table size="small" sx={{ borderCollapse: 'collapse', minWidth: 0 }}>
<TableBody>
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
{LL.VERSION()}
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{isPartition
? typeof version === 'string'
? version
: version?.version
: version?.version}
</TableCell>
</TableRow>
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13,
width: 140
}}
>
{isPartition ? LL.TYPE(0) : LL.RELEASE_TYPE()}
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{partition === currentPartition && LL.ACTIVE() + ' '}
{isStable
? LL.STABLE()
: isDev
? LL.DEVELOPMENT()
: 'Partition ' + LL.VERSION()}
</TableCell>
</TableRow>
{isPartition && (
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
Partition
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{partition}
</TableCell>
</TableRow>
)}
{isPartition && (
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
Size
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{size} KB
</TableCell>
</TableRow>
)}
{version && version.date && (
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
{isPartition ? 'Install Date' : 'Build Date'}
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{prettyDateTime(locale, new Date(version.date))}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</DialogContent>
<DialogActions>
{!isPartition && (
<Button
variant="outlined"
component="a"
href={relNotesUrl}
target="_blank"
color="primary"
>
Changelog
</Button>
)}
<Button variant="outlined" onClick={onClose} color="secondary">
{LL.CLOSE()}
</Button>
</DialogActions>
</Dialog>
);
}
);
const InstallDialog = memo(
({
openInstallDialog,
fetchDevVersion,
latestVersion,
latestDevVersion,
upgradeImportantMessageType,
downloadOnly,
platform,
LL,
onClose,
onInstall
}: {
openInstallDialog: boolean;
fetchDevVersion: boolean;
latestVersion: VersionInfo | undefined;
latestDevVersion: VersionInfo | undefined;
upgradeImportantMessageType: number;
downloadOnly: boolean;
platform: string;
LL: TranslationFunctions;
onClose: () => void;
onInstall: (url: string) => void;
}) => {
const binURL = (() => {
if (!latestVersion || !latestDevVersion) return '';
const version = fetchDevVersion ? latestDevVersion : latestVersion;
const filename = `EMS-ESP-${version.version.replaceAll('.', '_')}-${platform}.bin`;
return fetchDevVersion
? `${DEV_URL}${filename}`
: `${STABLE_URL}v${version.version}/${filename}`;
})();
return (
<Dialog sx={dialogStyle} open={openInstallDialog} onClose={onClose}>
<DialogTitle>
{`${LL.INSTALL()} ${fetchDevVersion ? LL.DEVELOPMENT() : LL.STABLE()} Firmware`}
</DialogTitle>
<DialogContent dividers>
<Typography sx={{ mb: 2 }}>
{LL.INSTALL_VERSION(
downloadOnly ? LL.DOWNLOAD(1) : LL.INSTALL(),
fetchDevVersion ? latestDevVersion?.version : latestVersion?.version
)}
</Typography>
{upgradeImportantMessageType === 2 && LL.UPGRADE_IMPORTANT_MESSAGES_2()}
{upgradeImportantMessageType === 1 && (
<>
{LL.UPGRADE_IMPORTANT_MESSAGES_1()}
<Typography sx={{ mt: 2 }}>
<Link to="/settings/downloadUpload" style={{ color: 'lightblue' }}>
{LL.DOWNLOAD_SYSTEM_BACKUP()}
</Link>
</Typography>
</>
)}
<Typography sx={{ mt: 2 }}>
<Link
to="https://docs.emsesp.org/FAQ#upgrading-the-firmware"
target="_blank"
rel="noreferrer"
style={{ color: 'lightblue' }}
>
{LL.ONLINE_HELP()}
</Link>
</Typography>
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<DownloadIcon />}
variant="outlined"
onClick={onClose}
color="primary"
>
<Link
to={binURL}
target="_blank"
rel="noreferrer"
style={{ color: 'lightblue', textDecoration: 'none' }}
>
{LL.DOWNLOAD(0)}
</Link>
</Button>
{!downloadOnly && (
<Button
startIcon={<WarningIcon color="warning" />}
variant="outlined"
onClick={() => onInstall(binURL)}
color="primary"
>
{LL.INSTALL()}
</Button>
)}
</DialogActions>
</Dialog>
);
}
);
const InstallPartitionDialog = memo(
({
openInstallPartitionDialog,
version,
partition,
LL,
onClose,
onInstall
}: {
openInstallPartitionDialog: boolean;
version: string;
partition: string;
LL: TranslationFunctions;
onClose: () => void;
onInstall: (partition: string) => void;
}) => {
return (
<Dialog sx={dialogStyle} open={openInstallPartitionDialog} onClose={onClose}>
<DialogTitle>
{LL.INSTALL()} {LL.STORED_VERSIONS()}
</DialogTitle>
<DialogContent dividers>
<Typography sx={{ mb: 2 }}>
{LL.INSTALL_VERSION(LL.INSTALL(), version)}
</Typography>
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
variant="outlined"
onClick={() => onInstall(partition)}
color="primary"
>
{LL.INSTALL()}
</Button>
</DialogActions>
</Dialog>
);
}
);
// 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, versions } = useContext(AuthenticatedContext);
const [restarting, setRestarting] = useState<boolean>(false);
const [confirmFactoryReset, setConfirmFactoryReset] = useState<boolean>(false);
const [confirmRestart, setConfirmRestart] = useState<boolean>(false);
const [openInstallDialog, setOpenInstallDialog] = useState<boolean>(false);
const [partitionVersion, setPartitionVersion] = useState<VersionInfo | undefined>(
undefined
);
const [partition, setPartition] = useState<string>('');
const [openInstallPartitionDialog, setOpenInstallPartitionDialog] =
useState<boolean>(false);
const [fetchDevVersion, setFetchDevVersion] = useState<boolean>(false);
const [downloadOnly, setDownloadOnly] = useState<boolean>(false);
const [showVersionInfo, setShowVersionInfo] = useState<number>(0); // 1 = stable, 2 = dev, 3 = partition
const [firmwareSize, setFirmwareSize] = useState<number>(0);
const latestVersion = useMemo<VersionInfo | undefined>(
() =>
versions?.stable
? { version: versions.stable.version, date: versions.stable.date }
: undefined,
[versions?.stable]
);
const latestDevVersion = useMemo<VersionInfo | undefined>(
() =>
versions?.dev
? { version: versions.dev.version, date: versions.dev.date }
: undefined,
[versions?.dev]
);
const usingDevVersion = versions?.current?.type === 'dev';
const stableUpgradeAvailable = versions?.stable?.upgradeable ?? false;
const devUpgradeAvailable = versions?.dev?.upgradeable ?? false;
const internetLive = Boolean(versions?.stable || versions?.dev);
const { send: sendSetPartition } = useRequest(
(partition: string) => callAction({ action: 'setPartition', param: partition }),
{ immediate: false }
).onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
const {
data,
send: loadData,
error
} = useRequest(SystemApi.readSystemStatus).onSuccess((event) => {
const systemData = event.data as VersionData;
if (systemData.arduino_version.startsWith('Tasmota')) {
setDownloadOnly(true);
}
});
const { send: sendUploadURL } = useRequest(
(url: string) => callAction({ action: 'uploadURL', param: url }),
{ immediate: false }
);
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false
});
const [upgradeImportantMessageType, setUpgradeImportantMessageType] =
useState<number>(0);
const { send: checkUpgradeImportantMessages } = useRequest(
(version: string) =>
callAction({ action: 'upgradeImportantMessages', param: version }),
{
immediate: false
}
)
.onSuccess((event) => {
const upgradeImportantMessageType_n = (
event.data as { upgradeImportantMessageType: number }
).upgradeImportantMessageType;
setUpgradeImportantMessageType(upgradeImportantMessageType_n);
})
.onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
const platform = data ? getPlatform(data) : '';
const otherPartitions =
data?.partitions.filter((p) => p.partition !== data.partition) ?? [];
const setPartitionVersionInfo = (partition: string) => {
setShowVersionInfo(3);
const partitionData = data?.partitions.find((p) => p.partition === partition);
if (partitionData) {
setPartitionVersion({
version: partitionData.version,
date: partitionData.install_date ?? ''
});
setPartition(partitionData.partition);
setFirmwareSize(partitionData.size);
}
};
const doRestart = async () => {
setConfirmRestart(false);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
setRestarting(true);
};
const doFormat = async () => {
await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => {
setRestarting(true);
setConfirmFactoryReset(false);
});
};
const handleFactoryResetClose = () => setConfirmFactoryReset(false);
const handleFactoryResetClick = () => setConfirmFactoryReset(true);
const handleRestartClose = () => setConfirmRestart(false);
const handleRestartClick = () => setConfirmRestart(true);
const installFirmwareURL = async (url: string) => {
await sendUploadURL(url).catch((error: Error) => {
toast.error(error.message);
});
await doRestart();
};
const installPartitionFirmware = async (partition: string) => {
await sendSetPartition(partition).catch((error: Error) => {
toast.error(error.message);
});
setRestarting(true);
};
const showPartitionDialog = (
version: string,
partition: string,
install_date: string
) => {
setOpenInstallPartitionDialog(true);
setPartitionVersion({ version: version, date: install_date });
setPartition(partition);
};
const showFirmwareDialog = (useDevVersion: boolean) => {
setFetchDevVersion(useDevVersion);
const targetVersion = useDevVersion
? latestDevVersion?.version
: latestVersion?.version;
if (targetVersion) {
void checkUpgradeImportantMessages(targetVersion);
}
setOpenInstallDialog(true);
};
const closeInstallDialog = () => setOpenInstallDialog(false);
const closeInstallPartitionDialog = () => setOpenInstallPartitionDialog(false);
const handleVersionInfoClose = () => {
setShowVersionInfo(0);
setPartitionVersion(undefined);
setPartition('');
};
useLayoutTitle('EMS-ESP Firmware');
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;
if (!choice) {
return (
<>
<CheckIcon
color="success"
sx={{ verticalAlign: 'middle', ml: 0.5, mr: 0.5 }}
/>
<span style={{ color: '#66bb6a', fontSize: '0.8em' }}>
{LL.LATEST_VERSION(usingDevVersion ? LL.DEVELOPMENT() : LL.STABLE())}
</span>
<Button
sx={{ ml: 1 }}
variant="outlined"
size="small"
onClick={() => showFirmwareDialog(showingDev)}
>
{LL.REINSTALL()}
</Button>
</>
);
}
if (!me.admin) return null;
const isUpdateAvailable = choice === LL.UPDATE_AVAILABLE();
return (
<Button
sx={{ ml: 1 }}
variant="outlined"
color={isUpdateAvailable ? 'success' : 'warning'}
size="small"
onClick={() => showFirmwareDialog(showingDev)}
>
{choice}
{isUpdateAvailable && (
<Box
component="span"
aria-label="update available"
sx={{
display: 'inline-block',
width: 8,
height: 8,
ml: 1,
verticalAlign: 'middle',
borderRadius: '50%',
backgroundColor: '#ffeb3b',
boxShadow: '0 0 6px rgba(255, 235, 59, 0.8)'
}}
/>
)}
</Button>
);
};
if (restarting) {
return <SystemMonitor />;
}
if (!data) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
return (
<SectionContent>
<Box sx={{ p: 2, border: '1px solid #565656', borderRadius: 2 }}>
<Typography sx={{ mb: 1 }} variant="h6" color="primary">
{LL.THIS_VERSION()}
</Typography>
<Grid
container
direction="row"
sx={{
justifyContent: 'flex-start',
alignItems: 'baseline'
}}
>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.VERSION()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{data.emsesp_version}
{data.build_flags && (
<Typography variant="caption">
&nbsp; &#40;{data.build_flags}&#41;
</Typography>
)}
<IconButton
onClick={() => setPartitionVersionInfo(data.partition)}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
</Typography>
</Grid>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.PLATFORM()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{platform}
<Typography variant="caption">
&nbsp; &#40;
{data.psram ? (
<CheckIcon
color="success"
sx={{
fontSize: '1.5em',
verticalAlign: 'middle'
}}
/>
) : (
<CloseIcon
color="error"
sx={{
fontSize: '1.5em',
verticalAlign: 'middle'
}}
/>
)}
PSRAM&#41;
</Typography>
</Typography>
</Grid>
</Grid>
{internetLive ? (
<>
<Typography sx={{ mt: 4, mb: 1 }} variant="h6" color="primary">
{LL.AVAILABLE_VERSION()}
</Typography>
<Grid
container
direction="row"
rowSpacing={1}
sx={{
justifyContent: 'flex-start',
alignItems: 'baseline'
}}
>
{otherPartitions.length > 0 && data.developer_mode && (
<>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.STORED_VERSIONS()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
{otherPartitions.map((partition) => (
<Typography key={partition.partition} sx={{ mb: 1 }}>
{partition.version}
<IconButton
onClick={() =>
setPartitionVersionInfo(partition.partition)
}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
<Button
sx={{ ml: 0 }}
variant="outlined"
size="small"
onClick={() =>
showPartitionDialog(
partition.version,
partition.partition,
partition.install_date ?? ''
)
}
>
{LL.INSTALL()}
</Button>
</Typography>
))}
</Grid>
</>
)}
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.STABLE()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{latestVersion?.version}
<IconButton
onClick={() => setShowVersionInfo(1)}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
{showButtons(false)}
</Typography>
</Grid>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.DEVELOPMENT()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{latestDevVersion?.version}
<IconButton
onClick={() => setShowVersionInfo(2)}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
{showButtons(true)}
</Typography>
</Grid>
</Grid>
</>
) : (
<Typography sx={{ mt: 2 }} color="warning">
<WarningIcon color="warning" sx={{ verticalAlign: 'middle', mr: 2 }} />
{LL.INTERNET_CONNECTION_REQUIRED()}
</Typography>
)}
{me.admin && (
<>
<VersionInfoDialog
showVersionInfo={showVersionInfo}
latestVersion={latestVersion}
latestDevVersion={latestDevVersion}
partitionVersion={partitionVersion}
locale={locale}
partition={partition}
currentPartition={data?.partition ?? ''}
size={firmwareSize}
LL={LL}
onClose={handleVersionInfoClose}
/>
<InstallDialog
openInstallDialog={openInstallDialog}
fetchDevVersion={fetchDevVersion}
latestVersion={latestVersion}
latestDevVersion={latestDevVersion}
upgradeImportantMessageType={upgradeImportantMessageType}
downloadOnly={downloadOnly}
platform={platform}
LL={LL}
onClose={closeInstallDialog}
onInstall={installFirmwareURL}
/>
<InstallPartitionDialog
openInstallPartitionDialog={openInstallPartitionDialog}
version={partitionVersion?.version || ''}
partition={partition}
LL={LL}
onClose={closeInstallPartitionDialog}
onInstall={installPartitionFirmware}
/>
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
{LL.UPLOAD()}
</Typography>
<SingleUpload doRestart={doRestart} />
</>
)}
</Box>
{me.admin && (
<>
<Dialog
sx={dialogStyle}
open={confirmFactoryReset}
onClose={handleFactoryResetClose}
>
<DialogTitle>{LL.FACTORY_RESET()}</DialogTitle>
<DialogContent dividers>{LL.SYSTEM_FACTORY_TEXT_DIALOG()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={handleFactoryResetClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={doFormat}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
</DialogActions>
</Dialog>
<Dialog
sx={dialogStyle}
open={confirmRestart}
onClose={handleRestartClose}
>
<DialogTitle>{LL.RESTART()}</DialogTitle>
<DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={handleRestartClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
onClick={doRestart}
color="error"
>
{LL.RESTART()}
</Button>
</DialogActions>
</Dialog>
<Box
sx={{
mt: 2,
display: 'flex',
justifyContent: 'flex-end',
flexWrap: 'nowrap',
whiteSpace: 'nowrap',
gap: 1
}}
>
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
onClick={handleRestartClick}
color="error"
>
{LL.RESTART()}
</Button>
{data.developer_mode && (
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={handleFactoryResetClick}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
)}
</Box>
</>
)}
</SectionContent>
);
};
export default memo(Version);

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useMemo, useState } from 'react'; import { memo, useState } from 'react';
import { import {
Navigate, Navigate,
Route, Route,
@@ -40,26 +40,20 @@ const Network = () => {
const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork>(); const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork>();
const selectNetwork = useCallback( const selectNetwork = (network: WiFiNetwork) => {
(network: WiFiNetwork) => { setSelectedNetwork(network);
setSelectedNetwork(network); void navigate('/settings/network/settings');
void navigate('/settings/network/settings'); };
},
[navigate]
);
const deselectNetwork = useCallback(() => { const deselectNetwork = () => {
setSelectedNetwork(undefined); setSelectedNetwork(undefined);
}, []); };
const contextValue = useMemo( const contextValue = {
() => ({ ...(selectedNetwork && { selectedNetwork }),
...(selectedNetwork && { selectedNetwork }), selectNetwork,
selectNetwork, deselectNetwork
deselectNetwork };
}),
[selectedNetwork, selectNetwork, deselectNetwork]
);
return ( return (
<WiFiConnectionContext.Provider value={contextValue}> <WiFiConnectionContext.Provider value={contextValue}>

View File

@@ -121,19 +121,19 @@ const NetworkSettings = () => {
deselectNetwork(); deselectNetwork();
}, [data, saveData, deselectNetwork]); }, [data, saveData, deselectNetwork]);
const setCancel = useCallback(async () => { const setCancel = async () => {
deselectNetwork(); deselectNetwork();
await loadData(); await loadData();
}, [deselectNetwork, loadData]); };
const doRestart = useCallback(async () => { const doRestart = async () => {
setRestarting(true); setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => { (error: Error) => {
toast.error(error.message); toast.error(error.message);
} }
); );
}, [sendAPI]); };
const content = () => { const content = () => {
if (!data) { if (!data) {

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useRef, useState } from 'react'; import { memo, useRef, useState } from 'react';
import PermScanWifiIcon from '@mui/icons-material/PermScanWifi'; import PermScanWifiIcon from '@mui/icons-material/PermScanWifi';
import { Button } from '@mui/material'; import { Button } from '@mui/material';
@@ -48,12 +48,12 @@ const WiFiNetworkScanner = () => {
} }
}); });
const renderNetworkScanner = useCallback(() => { const renderNetworkScanner = () => {
if (!networkList) { if (!networkList) {
return <FormLoader errorMessage={errorMessage || ''} />; return <FormLoader errorMessage={errorMessage || ''} />;
} }
return <WiFiNetworkSelector networkList={networkList} />; return <WiFiNetworkSelector networkList={networkList} />;
}, [networkList, errorMessage]); };
return ( return (
<SectionContent> <SectionContent>

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useContext } from 'react'; import { memo, useContext } from 'react';
import LockIcon from '@mui/icons-material/Lock'; import LockIcon from '@mui/icons-material/Lock';
import LockOpenIcon from '@mui/icons-material/LockOpen'; import LockOpenIcon from '@mui/icons-material/LockOpen';
@@ -63,34 +63,31 @@ const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList })
const wifiConnectionContext = useContext(WiFiConnectionContext); const wifiConnectionContext = useContext(WiFiConnectionContext);
const renderNetwork = useCallback( const renderNetwork = (network: WiFiNetwork) => (
(network: WiFiNetwork) => ( <ListItem
<ListItem key={network.bssid}
key={network.bssid} onClick={() => wifiConnectionContext.selectNetwork(network)}
onClick={() => wifiConnectionContext.selectNetwork(network)} >
> <ListItemAvatar>
<ListItemAvatar> <Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
<Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar> </ListItemAvatar>
</ListItemAvatar> <ListItemText
<ListItemText primary={network.ssid}
primary={network.ssid} secondary={
secondary={ 'Security: ' +
'Security: ' + networkSecurityMode(network) +
networkSecurityMode(network) + ', Ch: ' +
', Ch: ' + network.channel +
network.channel + ', bssid: ' +
', bssid: ' + network.bssid
network.bssid }
} />
/> <ListItemIcon>
<ListItemIcon> <Badge badgeContent={network.rssi + 'dBm'}>
<Badge badgeContent={network.rssi + 'dBm'}> <WifiIcon sx={{ color: networkQualityHighlight(network, theme) }} />
<WifiIcon sx={{ color: networkQualityHighlight(network, theme) }} /> </Badge>
</Badge> </ListItemIcon>
</ListItemIcon> </ListItem>
</ListItem>
),
[wifiConnectionContext, theme]
); );
if (networkList.networks.length === 0) { if (networkList.networks.length === 0) {

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useContext, useMemo, useState } from 'react'; import { memo, useCallback, useContext, useState } from 'react';
import { useBlocker } from 'react-router'; import { useBlocker } from 'react-router';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -55,16 +55,14 @@ const ManageUsers = () => {
const blocker = useBlocker(changed !== 0); const blocker = useBlocker(changed !== 0);
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const table_theme = useMemo( const table_theme = useTheme({
() => Table: `
useTheme({
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) minmax(120px, max-content) 120px; --data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) minmax(120px, max-content) 120px;
`, `,
BaseRow: ` BaseRow: `
font-size: 14px; font-size: 14px;
`, `,
HeaderRow: ` HeaderRow: `
text-transform: uppercase; text-transform: uppercase;
background-color: black; background-color: black;
color: #90CAF9; color: #90CAF9;
@@ -74,7 +72,7 @@ const ManageUsers = () => {
border-bottom: 1px solid #565656; border-bottom: 1px solid #565656;
} }
`, `,
Row: ` Row: `
.td { .td {
padding: 8px; padding: 8px;
border-top: 1px solid #565656; border-top: 1px solid #565656;
@@ -87,7 +85,7 @@ const ManageUsers = () => {
background-color: #1e1e1e; background-color: #1e1e1e;
} }
`, `,
BaseCell: ` BaseCell: `
&:nth-of-type(2) { &:nth-of-type(2) {
text-align: center; text-align: center;
} }
@@ -95,44 +93,36 @@ const ManageUsers = () => {
text-align: right; text-align: right;
} }
` `
}), });
[]
);
const noAdminConfigured = useCallback( const noAdminConfigured = () => !data?.users.find((u) => u.admin);
() => !data?.users.find((u) => u.admin),
[data]
);
const removeUser = useCallback( const removeUser = (toRemove: UserType) => {
(toRemove: UserType) => { if (!data) return;
if (!data) return; const users = data.users.filter((u) => u.username !== toRemove.username);
const users = data.users.filter((u) => u.username !== toRemove.username); updateDataValue({ ...data, users });
updateDataValue({ ...data, users }); setChanged(changed + 1);
setChanged(changed + 1); };
},
[data, updateDataValue, changed]
);
const createUser = useCallback(() => { const createUser = () => {
setCreating(true); setCreating(true);
setUser({ setUser({
username: '', username: '',
password: '', password: '',
admin: true admin: true
}); });
}, []); };
const editUser = useCallback((toEdit: UserType) => { const editUser = (toEdit: UserType) => {
setCreating(false); setCreating(false);
setUser({ ...toEdit }); setUser({ ...toEdit });
}, []); };
const cancelEditingUser = useCallback(() => { const cancelEditingUser = () => {
setUser(undefined); setUser(undefined);
}, []); };
const doneEditingUser = useCallback(() => { const doneEditingUser = () => {
if (user && data) { if (user && data) {
const users = [ const users = [
...data.users.filter( ...data.users.filter(
@@ -144,26 +134,26 @@ const ManageUsers = () => {
setUser(undefined); setUser(undefined);
setChanged(changed + 1); setChanged(changed + 1);
} }
}, [user, data, updateDataValue, changed]); };
const closeGenerateToken = useCallback(() => { const closeGenerateToken = useCallback(() => {
setGeneratingToken(undefined); setGeneratingToken(undefined);
}, []); }, []);
const generateTokenForUser = useCallback((username: string) => { const generateTokenForUser = (username: string) => {
setGeneratingToken(username); setGeneratingToken(username);
}, []); };
const onSubmit = useCallback(async () => { const onSubmit = async () => {
await saveData(); await saveData();
await authenticatedContext.refresh(); await authenticatedContext.refresh();
setChanged(0); setChanged(0);
}, [saveData, authenticatedContext]); };
const onCancelSubmit = useCallback(async () => { const onCancelSubmit = async () => {
await loadData(); await loadData();
setChanged(0); setChanged(0);
}, [loadData]); };
const content = () => { const content = () => {
if (!data) { if (!data) {
@@ -177,15 +167,10 @@ const ManageUsers = () => {
admin: boolean; admin: boolean;
} }
// add id to the type, needed for the table const user_table = data.users.map((u) => ({
const user_table = useMemo( ...u,
() => id: u.username
data.users.map((u) => ({ })) as UserType2[];
...u,
id: u.username
})) as UserType2[],
[data.users]
);
return ( return (
<> <>

View File

@@ -1,4 +1,4 @@
import { memo, useMemo } from 'react'; import { memo } from 'react';
import { Navigate, Route, Routes, matchRoutes, useLocation } from 'react-router'; import { Navigate, Route, Routes, matchRoutes, useLocation } from 'react-router';
import { Tab } from '@mui/material'; import { Tab } from '@mui/material';
@@ -15,19 +15,15 @@ const Security = () => {
const location = useLocation(); const location = useLocation();
const matchedRoutes = useMemo( const matchedRoutes = matchRoutes(
() => [
matchRoutes( {
[ path: '/settings/security/settings',
{ element: <ManageUsers />
path: '/settings/security/settings', },
element: <ManageUsers /> { path: '/settings/security/users', element: <SecuritySettings /> }
}, ],
{ path: '/settings/security/users', element: <SecuritySettings /> } location
],
location
),
[location]
); );
const routerTab = matchedRoutes?.[0]?.route.path || false; const routerTab = matchedRoutes?.[0]?.route.path || false;

View File

@@ -79,7 +79,7 @@ const SecuritySettings = () => {
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
/> />
<MessageBox level="info" message={LL.SU_TEXT()} mt={1} /> <MessageBox level="info" message={LL.SU_TEXT()} sx={{ mt: 1 }} />
{dirtyFlags && dirtyFlags.length !== 0 && ( {dirtyFlags && dirtyFlags.length !== 0 && (
<ButtonRow> <ButtonRow>
<Button <Button

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useState } from 'react'; import { memo, useEffect, useState } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -62,7 +62,7 @@ const User: FC<UserFormProps> = ({
} }
}, [open]); }, [open]);
const validateAndDone = useCallback(async () => { const validateAndDone = async () => {
if (user) { if (user) {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
@@ -72,7 +72,7 @@ const User: FC<UserFormProps> = ({
setFieldErrors((error as ValidationError).fieldErrors); setFieldErrors((error as ValidationError).fieldErrors);
} }
} }
}, [user, validator, onDoneEditing]); };
return ( return (
<Dialog <Dialog

View File

@@ -1,5 +1,3 @@
import { useCallback, useMemo } from 'react';
import { import {
Body, Body,
Cell, Cell,
@@ -36,16 +34,14 @@ const SystemActivity = () => {
useLayoutTitle(LL.DATA_TRAFFIC()); useLayoutTitle(LL.DATA_TRAFFIC());
const stats_theme = tableTheme( const stats_theme = tableTheme({
useMemo( Table: `
() => ({
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 90px 90px 80px; --data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 90px 90px 80px;
`, `,
BaseRow: ` BaseRow: `
font-size: 14px; font-size: 14px;
`, `,
HeaderRow: ` HeaderRow: `
text-transform: uppercase; text-transform: uppercase;
background-color: black; background-color: black;
color: #90CAF9; color: #90CAF9;
@@ -55,7 +51,7 @@ const SystemActivity = () => {
border-bottom: 1px solid #565656; border-bottom: 1px solid #565656;
} }
`, `,
Row: ` Row: `
.td { .td {
padding: 8px; padding: 8px;
border-top: 1px solid #565656; border-top: 1px solid #565656;
@@ -69,26 +65,20 @@ const SystemActivity = () => {
background-color: #1e1e1e; background-color: #1e1e1e;
} }
`, `,
BaseCell: ` BaseCell: `
&:not(:first-of-type) { &:not(:first-of-type) {
text-align: center; text-align: center;
} }
` `
}), });
[]
)
);
const showName = useCallback( const showName = (id: number) => {
(id: number) => { const name: keyof Translation['STATUS_NAMES'] =
const name: keyof Translation['STATUS_NAMES'] = id.toString() as keyof Translation['STATUS_NAMES'];
id.toString() as keyof Translation['STATUS_NAMES']; return LL.STATUS_NAMES[name]();
return LL.STATUS_NAMES[name](); };
},
[LL]
);
const showQuality = useCallback((stat: Stat) => { const showQuality = (stat: Stat) => {
if (stat.q === 0 || stat.s + stat.f === 0) { if (stat.q === 0 || stat.s + stat.f === 0) {
return; return;
} }
@@ -100,14 +90,18 @@ const SystemActivity = () => {
} else { } else {
return <div style={{ color: QUALITY_COLORS.POOR }}>{stat.q}%</div>; return <div style={{ color: QUALITY_COLORS.POOR }}>{stat.q}%</div>;
} }
}, []); };
const content = useMemo(() => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
if (!data) {
return ( return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
return (
<SectionContent>
<Table <Table
data={{ nodes: data.stats }} data={{ nodes: data.stats }}
theme={stats_theme} theme={stats_theme}
@@ -136,10 +130,8 @@ const SystemActivity = () => {
</> </>
)} )}
</Table> </Table>
); </SectionContent>
}, [data, loadData, error?.message, stats_theme, LL, showName, showQuality]); );
return <SectionContent>{content}</SectionContent>;
}; };
export default SystemActivity; export default SystemActivity;

View File

@@ -1,4 +1,4 @@
import { type FC, memo, useMemo } from 'react'; import { type FC, memo } from 'react';
import AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion'; import AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion';
import DeviceHubIcon from '@mui/icons-material/DeviceHub'; import DeviceHubIcon from '@mui/icons-material/DeviceHub';
@@ -127,16 +127,15 @@ const MqttStatus = () => {
void loadData(); void loadData();
}); });
// Memoize error message separately to avoid re-renders on error object changes
const errorMessage = error?.message || ''; const errorMessage = error?.message || '';
const mqttStatusText = useMemo(() => { const mqttStatusText = !data
if (!data) return ''; ? ''
if (!data.enabled) return LL.NOT_ENABLED(); : !data.enabled
return data.connected ? LL.NOT_ENABLED()
? `${LL.CONNECTED(0)} (${data.connect_count})` : data.connected
: `${LL.DISCONNECTED()} (${data.connect_count})`; ? `${LL.CONNECTED(0)} (${data.connect_count})`
}, [data, LL]); : `${LL.DISCONNECTED()} (${data.connect_count})`;
if (!data) { if (!data) {
return ( return (

View File

@@ -1,5 +1,3 @@
import { useMemo } from 'react';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
import DnsIcon from '@mui/icons-material/Dns'; import DnsIcon from '@mui/icons-material/Dns';
import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle'; import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle';
@@ -67,12 +65,16 @@ const NTPStatus = () => {
} }
}; };
const content = useMemo(() => { if (!data) {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
return ( return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
return (
<SectionContent>
<List> <List>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
@@ -121,10 +123,8 @@ const NTPStatus = () => {
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
</List> </List>
); </SectionContent>
}, [data, error, loadData, LL, theme]); );
return <SectionContent>{content}</SectionContent>;
}; };
export default NTPStatus; export default NTPStatus;

View File

@@ -1,5 +1,3 @@
import { useMemo } from 'react';
import DeviceHubIcon from '@mui/icons-material/DeviceHub'; import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import DnsIcon from '@mui/icons-material/Dns'; import DnsIcon from '@mui/icons-material/Dns';
import GiteIcon from '@mui/icons-material/Gite'; import GiteIcon from '@mui/icons-material/Gite';
@@ -124,16 +122,20 @@ const NetworkStatus = () => {
const theme = useTheme(); const theme = useTheme();
const content = useMemo(() => { if (!data) {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
const statusText = getNetworkStatusText(data.status, data.reconnect_count, LL);
const statusColor = networkStatusHighlight(data, theme);
const qualityColor = networkQualityHighlight(data, theme);
return ( return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
const statusText = getNetworkStatusText(data.status, data.reconnect_count, LL);
const statusColor = networkStatusHighlight(data, theme);
const qualityColor = networkQualityHighlight(data, theme);
return (
<SectionContent>
<List> <List>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
@@ -227,10 +229,8 @@ const NetworkStatus = () => {
</> </>
)} )}
</List> </List>
); </SectionContent>
}, [data, error, loadData, LL, theme]); );
return <SectionContent>{content}</SectionContent>;
}; };
export default NetworkStatus; export default NetworkStatus;

View File

@@ -1,25 +1,16 @@
import { useCallback, useContext, useMemo, useState } from 'react'; import { useContext } from 'react';
import { toast } from 'react-toastify';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
import BuildIcon from '@mui/icons-material/Build';
import CancelIcon from '@mui/icons-material/Cancel';
import DeviceHubIcon from '@mui/icons-material/DeviceHub'; import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import DirectionsBusIcon from '@mui/icons-material/DirectionsBus'; import DirectionsBusIcon from '@mui/icons-material/DirectionsBus';
import LogoDevIcon from '@mui/icons-material/LogoDev'; import LogoDevIcon from '@mui/icons-material/LogoDev';
import MemoryIcon from '@mui/icons-material/Memory'; import MemoryIcon from '@mui/icons-material/Memory';
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart'; import MonitorHeartIcon from '@mui/icons-material/MonitorHeart';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import RouterIcon from '@mui/icons-material/Router'; import RouterIcon from '@mui/icons-material/Router';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna'; import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import WifiIcon from '@mui/icons-material/Wifi'; import WifiIcon from '@mui/icons-material/Wifi';
import { import {
Avatar, Avatar,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
List, List,
ListItem, ListItem,
ListItemAvatar, ListItemAvatar,
@@ -27,12 +18,10 @@ import {
useTheme useTheme
} from '@mui/material'; } from '@mui/material';
import { API } from 'api/app';
import { readSystemStatus } from 'api/system'; import { readSystemStatus } from 'api/system';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client'; import { useRequest } from 'alova/client';
import { type APIcall, busConnectionStatus } from 'app/main/types'; import { busConnectionStatus } from 'app/main/types';
import { FormLoader, SectionContent, useLayoutTitle } from 'components'; import { FormLoader, SectionContent, useLayoutTitle } from 'components';
import ListMenuItem from 'components/layout/ListMenuItem'; import ListMenuItem from 'components/layout/ListMenuItem';
import { AuthenticatedContext } from 'contexts/authentication'; import { AuthenticatedContext } from 'contexts/authentication';
@@ -41,9 +30,6 @@ import { NTPSyncStatus, NetworkConnectionStatus, SystemStatusCodes } from 'types
import { useInterval } from 'utils'; import { useInterval } from 'utils';
import { formatDateTime } from 'utils/time'; import { formatDateTime } from 'utils/time';
import SystemMonitor from './SystemMonitor';
// Pure functions moved outside component to avoid recreation on each render
const formatNumber = (num: number) => new Intl.NumberFormat().format(num); const formatNumber = (num: number) => new Intl.NumberFormat().format(num);
const formatDurationSec = ( const formatDurationSec = (
@@ -72,24 +58,7 @@ const SystemStatus = () => {
const { me } = useContext(AuthenticatedContext); const { me } = useContext(AuthenticatedContext);
const [confirmRestart, setConfirmRestart] = useState<boolean>(false); const { data, send: loadData, error } = useRequest(readSystemStatus);
const [restarting, setRestarting] = useState<boolean>();
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false
});
const {
data,
send: loadData,
error
} = useRequest(readSystemStatus, {
async middleware(_, next) {
if (!restarting) {
await next();
}
}
});
useInterval(() => { useInterval(() => {
void loadData(); void loadData();
@@ -97,10 +66,8 @@ const SystemStatus = () => {
const theme = useTheme(); const theme = useTheme();
// Memoize derived status values to avoid recalculation on every render const busStatus = (() => {
const busStatus = useMemo(() => {
if (!data) return 'EMS state unknown'; if (!data) return 'EMS state unknown';
switch (data.bus_status) { switch (data.bus_status) {
case busConnectionStatus.BUS_STATUS_CONNECTED: case busConnectionStatus.BUS_STATUS_CONNECTED:
return `EMS ${LL.CONNECTED(0)} (${formatDurationSec(data.bus_uptime, LL)})`; return `EMS ${LL.CONNECTED(0)} (${formatDurationSec(data.bus_uptime, LL)})`;
@@ -111,12 +78,10 @@ const SystemStatus = () => {
default: default:
return 'EMS state unknown'; return 'EMS state unknown';
} }
}, [data?.bus_status, data?.bus_uptime, LL]); })();
// Memoize derived status values to avoid recalculation on every render const systemStatus = (() => {
const systemStatus = useMemo(() => {
if (!data) return '??'; if (!data) return '??';
switch (data.status) { switch (data.status) {
case SystemStatusCodes.SYSTEM_STATUS_PENDING_UPLOAD: case SystemStatusCodes.SYSTEM_STATUS_PENDING_UPLOAD:
case SystemStatusCodes.SYSTEM_STATUS_UPLOADING: case SystemStatusCodes.SYSTEM_STATUS_UPLOADING:
@@ -129,14 +94,12 @@ const SystemStatus = () => {
case SystemStatusCodes.SYSTEM_STATUS_INVALID_GPIO: case SystemStatusCodes.SYSTEM_STATUS_INVALID_GPIO:
return LL.GPIO_OF(LL.FAILED(0)); return LL.GPIO_OF(LL.FAILED(0));
default: default:
// SystemStatusCodes.SYSTEM_STATUS_NORMAL
return 'OK'; return 'OK';
} }
}, [data?.status, LL]); })();
const busStatusHighlight = useMemo(() => { const busStatusHighlight = (() => {
if (!data) return theme.palette.warning.main; if (!data) return theme.palette.warning.main;
switch (data.bus_status) { switch (data.bus_status) {
case busConnectionStatus.BUS_STATUS_TX_ERRORS: case busConnectionStatus.BUS_STATUS_TX_ERRORS:
return theme.palette.warning.main; return theme.palette.warning.main;
@@ -147,11 +110,10 @@ const SystemStatus = () => {
default: default:
return theme.palette.warning.main; return theme.palette.warning.main;
} }
}, [data?.bus_status, theme.palette]); })();
const ntpStatus = useMemo(() => { const ntpStatus = (() => {
if (!data) return LL.UNKNOWN(); if (!data) return LL.UNKNOWN();
switch (data.ntp_status) { switch (data.ntp_status) {
case NTPSyncStatus.NTP_DISABLED: case NTPSyncStatus.NTP_DISABLED:
return LL.NOT_ENABLED(); return LL.NOT_ENABLED();
@@ -164,11 +126,10 @@ const SystemStatus = () => {
default: default:
return LL.UNKNOWN(); return LL.UNKNOWN();
} }
}, [data?.ntp_status, data?.ntp_time, LL]); })();
const ntpStatusHighlight = useMemo(() => { const ntpStatusHighlight = (() => {
if (!data) return theme.palette.error.main; if (!data) return theme.palette.error.main;
switch (data.ntp_status) { switch (data.ntp_status) {
case NTPSyncStatus.NTP_DISABLED: case NTPSyncStatus.NTP_DISABLED:
return theme.palette.info.main; return theme.palette.info.main;
@@ -179,11 +140,10 @@ const SystemStatus = () => {
default: default:
return theme.palette.error.main; return theme.palette.error.main;
} }
}, [data?.ntp_status, theme.palette]); })();
const networkStatusHighlight = useMemo(() => { const networkStatusHighlight = (() => {
if (!data) return theme.palette.warning.main; if (!data) return theme.palette.warning.main;
switch (data.network_status) { switch (data.network_status) {
case NetworkConnectionStatus.WIFI_STATUS_IDLE: case NetworkConnectionStatus.WIFI_STATUS_IDLE:
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED: case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
@@ -198,11 +158,10 @@ const SystemStatus = () => {
default: default:
return theme.palette.warning.main; return theme.palette.warning.main;
} }
}, [data?.network_status, theme.palette]); })();
const networkStatus = useMemo(() => { const networkStatus = (() => {
if (!data) return LL.UNKNOWN(); if (!data) return LL.UNKNOWN();
switch (data.network_status) { switch (data.network_status) {
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD: case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return LL.INACTIVE(1); return LL.INACTIVE(1);
@@ -223,227 +182,103 @@ const SystemStatus = () => {
default: default:
return LL.UNKNOWN(); return LL.UNKNOWN();
} }
}, [data?.network_status, data?.wifi_rssi, LL]); })();
const activeHighlight = useCallback( const activeHighlight = (value: boolean) =>
(value: boolean) => value ? theme.palette.success.main : theme.palette.info.main;
value ? theme.palette.success.main : theme.palette.info.main,
[theme.palette]
);
const doRestart = useCallback(async () => {
setConfirmRestart(false);
setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
}, [sendAPI]);
const handleCloseRestartDialog = useCallback(() => {
setConfirmRestart(false);
}, []);
const renderRestartDialog = useMemo(
() => (
<Dialog
sx={dialogStyle}
open={confirmRestart}
onClose={handleCloseRestartDialog}
>
<DialogTitle>{LL.RESTART()}</DialogTitle>
<DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={handleCloseRestartDialog}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
onClick={doRestart}
color="error"
>
{LL.RESTART()}
</Button>
</DialogActions>
</Dialog>
),
[confirmRestart, handleCloseRestartDialog, doRestart, LL]
);
// Memoize formatted values
const firmwareVersion = useMemo(
() => `v${data?.emsesp_version || ''}`,
[data?.emsesp_version]
);
const uptimeText = useMemo(
() => (data ? formatDurationSec(data.uptime, LL) : ''),
[data?.uptime, LL]
);
const freeMemoryText = useMemo(
() => (data ? `${formatNumber(data.free_heap)} KB ${LL.FREE_MEMORY()}` : ''),
[data?.free_heap, LL]
);
const networkIcon = useMemo(
() =>
data?.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED
? WifiIcon
: RouterIcon,
[data?.network_status]
);
const mqttStatusText = useMemo(
() => (data?.mqtt_status ? LL.CONNECTED(0) : LL.INACTIVE(0)),
[data?.mqtt_status, LL]
);
const apStatusText = useMemo(
() => (data?.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)),
[data?.ap_status, LL]
);
const handleRestartClick = useCallback(() => {
setConfirmRestart(true);
}, []);
const content = useMemo(() => {
if (!data || !LL) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
if (!data || !LL) {
return ( return (
<> <SectionContent>
<List> <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
<ListMenuItem </SectionContent>
icon={BuildIcon}
bgcolor="#72caf9"
label="EMS-ESP Firmware"
text={firmwareVersion}
to="version"
/>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}>
<MonitorHeartIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.STATUS_OF(LL.SYSTEM(0))}
secondary={`${systemStatus} (${LL.UPTIME()}: ${uptimeText})`}
/>
{me.admin && (
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
color="error"
onClick={handleRestartClick}
>
{LL.RESTART()}
</Button>
)}
</ListItem>
<ListMenuItem
disabled={!me.admin}
icon={MemoryIcon}
bgcolor="#68374d"
label={LL.HARDWARE()}
text={freeMemoryText}
to="/status/hardwarestatus"
/>
<ListMenuItem
disabled={!me.admin}
icon={DirectionsBusIcon}
bgcolor={busStatusHighlight}
label={LL.DATA_TRAFFIC()}
text={busStatus}
to="/status/activity"
/>
<ListMenuItem
disabled={!me.admin}
icon={networkIcon}
bgcolor={networkStatusHighlight}
label={LL.NETWORK(1)}
text={networkStatus}
to="/status/network"
/>
<ListMenuItem
disabled={!me.admin}
icon={DeviceHubIcon}
bgcolor={activeHighlight(data.mqtt_status)}
label="MQTT"
text={mqttStatusText}
to="/status/mqtt"
/>
<ListMenuItem
disabled={!me.admin}
icon={AccessTimeIcon}
bgcolor={ntpStatusHighlight}
label="NTP"
text={ntpStatus}
to="/status/ntp"
/>
<ListMenuItem
disabled={!me.admin}
icon={SettingsInputAntennaIcon}
bgcolor={activeHighlight(data.ap_status)}
label={LL.ACCESS_POINT(0)}
text={apStatusText}
to="/status/ap"
/>
<ListMenuItem
disabled={!me.admin}
icon={LogoDevIcon}
bgcolor="#40828f"
label={LL.LOG_OF(LL.SYSTEM(0))}
text={LL.VIEW_LOG()}
to="/status/log"
/>
</List>
{renderRestartDialog}
</>
); );
}, [ }
data,
LL,
firmwareVersion,
uptimeText,
freeMemoryText,
networkIcon,
mqttStatusText,
apStatusText,
busStatus,
busStatusHighlight,
networkStatusHighlight,
networkStatus,
ntpStatusHighlight,
ntpStatus,
activeHighlight,
me.admin,
handleRestartClick,
error,
loadData,
renderRestartDialog
]);
return restarting ? <SystemMonitor /> : <SectionContent>{content}</SectionContent>; return (
<SectionContent>
<List>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}>
<MonitorHeartIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.STATUS_OF(LL.SYSTEM(0))}
secondary={`${systemStatus} (${LL.UPTIME()}: ${formatDurationSec(data.uptime, LL)})`}
/>
</ListItem>
<ListMenuItem
disabled={!me.admin}
icon={MemoryIcon}
bgcolor="#68374d"
label={LL.HARDWARE()}
text={`${formatNumber(data.free_heap)} KB ${LL.FREE_MEMORY()}`}
to="/status/hardwarestatus"
/>
<ListMenuItem
disabled={!me.admin}
icon={DirectionsBusIcon}
bgcolor={busStatusHighlight}
label={LL.DATA_TRAFFIC()}
text={busStatus}
to="/status/activity"
/>
<ListMenuItem
disabled={!me.admin}
icon={
data.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED
? WifiIcon
: RouterIcon
}
bgcolor={networkStatusHighlight}
label={LL.NETWORK(1)}
text={networkStatus}
to="/status/network"
/>
<ListMenuItem
disabled={!me.admin}
icon={DeviceHubIcon}
bgcolor={activeHighlight(data.mqtt_status)}
label="MQTT"
text={data.mqtt_status ? LL.CONNECTED(0) : LL.INACTIVE(0)}
to="/status/mqtt"
/>
<ListMenuItem
disabled={!me.admin}
icon={AccessTimeIcon}
bgcolor={ntpStatusHighlight}
label="NTP"
text={ntpStatus}
to="/status/ntp"
/>
<ListMenuItem
disabled={!me.admin}
icon={SettingsInputAntennaIcon}
bgcolor={activeHighlight(data.ap_status)}
label={LL.ACCESS_POINT(0)}
text={data.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)}
to="/status/ap"
/>
<ListMenuItem
disabled={!me.admin}
icon={LogoDevIcon}
bgcolor="#40828f"
label={LL.LOG_OF(LL.SYSTEM(0))}
text={LL.VIEW_LOG()}
to="/status/log"
/>
</List>
</SectionContent>
);
}; };
export default SystemStatus; export default SystemStatus;

View File

@@ -1,11 +1,4 @@
import { import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react';
memo,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState
} from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import DownloadIcon from '@mui/icons-material/GetApp'; import DownloadIcon from '@mui/icons-material/GetApp';
@@ -185,8 +178,7 @@ const SystemLog = () => {
}; };
}, [data]); // Recalculate when data changes (in case layout shifts) }, [data]); // Recalculate when data changes (in case layout shifts)
// Memoize message handler to avoid recreating on every render const handleLogMessage = (message: { data: string }) => {
const handleLogMessage = useCallback((message: { data: string }) => {
const rawData = message.data; const rawData = message.data;
const logentry = JSON.parse(rawData) as LogEntry; const logentry = JSON.parse(rawData) as LogEntry;
setLogEntries((log) => { setLogEntries((log) => {
@@ -200,7 +192,7 @@ const SystemLog = () => {
const newLog = [...log, logentry]; const newLog = [...log, logentry];
return newLog; return newLog;
}); });
}, []); };
useSSE(fetchLogES, { useSSE(fetchLogES, {
immediate: true, immediate: true,
@@ -211,7 +203,7 @@ const SystemLog = () => {
toast.error('No connection to Log service'); toast.error('No connection to Log service');
}); });
const onDownload = useCallback(() => { const onDownload = () => {
const result = logEntries const result = logEntries
.map((i) => `${i.t} ${levelLabel(i.l)} ${i.i}: [${i.n}] ${i.m}`) .map((i) => `${i.t} ${levelLabel(i.l)} ${i.i}: [${i.n}] ${i.m}`)
.join('\n'); .join('\n');
@@ -225,11 +217,11 @@ const SystemLog = () => {
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
}, [logEntries]); };
const saveSettings = useCallback(async () => { const saveSettings = async () => {
await saveData(); await saveData();
}, [saveData]); };
// handle scrolling - optimized to only scroll when needed // handle scrolling - optimized to only scroll when needed
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@@ -246,7 +238,7 @@ const SystemLog = () => {
} }
}, [logEntries.length, autoscroll]); }, [logEntries.length, autoscroll]);
const sendReadCommand = useCallback(() => { const sendReadCommand = () => {
if (readValue === '') { if (readValue === '') {
setReadOpen(!readOpen); setReadOpen(!readOpen);
return; return;
@@ -257,7 +249,7 @@ const SystemLog = () => {
setReadOpen(false); setReadOpen(false);
setReadValue(''); setReadValue('');
} }
}, [readValue, readOpen, send]); };
const content = () => { const content = () => {
if (!data) { if (!data) {

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from 'react'; import { useRef, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import { Box, Button, Typography } from '@mui/material'; import { Box, Button, Typography } from '@mui/material';
@@ -57,39 +57,31 @@ const SystemMonitor = () => {
void send(); void send();
}, 1000); // check every 1 second }, 1000); // check every 1 second
const { statusMessage, isUploading, progressValue } = useMemo(() => { const status = data?.status;
const status = data?.status;
const message = const statusMessage =
status && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING status && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING
? LL.WAIT_FIRMWARE() ? LL.WAIT_FIRMWARE()
: status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART : status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART
? LL.APPLICATION_RESTARTING() ? LL.APPLICATION_RESTARTING()
: status === SystemStatusCodes.SYSTEM_STATUS_NORMAL : status === SystemStatusCodes.SYSTEM_STATUS_NORMAL
? LL.RESTARTING_PRE() ? LL.RESTARTING_PRE()
: status === SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD : status === SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD
? 'Upload Failed' ? 'Upload Failed'
: LL.RESTARTING_POST(); : LL.RESTARTING_POST();
const uploading = const isUploading =
status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING; status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING;
const progress = const progressValue =
uploading && status isUploading && status
? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING) ? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING)
: 0; : 0;
return { const onCancel = async () => {
statusMessage: message,
isUploading: uploading,
progressValue: progress
};
}, [data?.status, LL]);
const onCancel = useCallback(async () => {
setErrorMessage(undefined); setErrorMessage(undefined);
await setSystemStatus(String(SystemStatusCodes.SYSTEM_STATUS_NORMAL)); await setSystemStatus(String(SystemStatusCodes.SYSTEM_STATUS_NORMAL));
document.location.href = '/'; document.location.href = '/';
}, [setSystemStatus]); };
return ( return (
<Box <Box

View File

@@ -1,922 +0,0 @@
import {
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState
} from 'react';
import { Link } from 'react-router';
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,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Grid,
IconButton,
Table,
TableBody,
TableCell,
TableRow,
Typography
} from '@mui/material';
import * as SystemApi from 'api/system';
import { API, callAction } from 'api/app';
import { getDevVersion, getStableVersion } from 'api/system';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import type { APIcall } from 'app/main/types';
import SystemMonitor from 'app/status/SystemMonitor';
import {
FormLoader,
SectionContent,
SingleUpload,
useLayoutTitle
} 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
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';
// Types for better type safety
interface PartitionData {
partition: string;
version: string;
install_date?: string;
size: number;
}
interface VersionData {
emsesp_version: string;
arduino_version: string;
esp_platform: string;
flash_chip_size: number;
psram: boolean;
build_flags?: string;
partition: string;
partitions: PartitionData[];
developer_mode: boolean;
}
interface UpgradeCheckData {
emsesp_version: string;
dev_upgradeable: boolean;
stable_upgradeable: boolean;
}
interface VersionInfo {
name: string;
published_at?: string;
}
// Memoized components for better performance
const VersionInfoDialog = memo(
({
showVersionInfo,
latestVersion,
latestDevVersion,
partitionVersion,
partition,
currentPartition,
size,
locale,
LL,
onClose
}: {
showVersionInfo: number;
latestVersion?: VersionInfo;
latestDevVersion?: VersionInfo;
partitionVersion?: VersionInfo | undefined;
partition: string;
currentPartition: string;
size: number;
locale: string;
LL: TranslationFunctions;
onClose: () => void;
}) => {
if (showVersionInfo === 0) return null;
const isStable = showVersionInfo === 1;
const isDev = showVersionInfo === 2;
const isPartition = showVersionInfo === 3;
const version = isStable
? latestVersion
: isDev
? latestDevVersion
: partitionVersion;
const relNotesUrl = isStable
? STABLE_RELNOTES_URL
: isDev
? DEV_RELNOTES_URL
: '';
return (
<Dialog sx={dialogStyle} open={showVersionInfo !== 0} onClose={onClose}>
<DialogTitle>{LL.FIRMWARE_VERSION_INFO()}</DialogTitle>
<DialogContent dividers>
<Table size="small" sx={{ borderCollapse: 'collapse', minWidth: 0 }}>
<TableBody>
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
{LL.VERSION()}
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{isPartition
? typeof version === 'string'
? version
: version?.name
: version?.name}
</TableCell>
</TableRow>
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13,
width: 140
}}
>
{isPartition ? LL.TYPE(0) : LL.RELEASE_TYPE()}
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{partition === currentPartition && LL.ACTIVE() + ' '}
{isStable
? LL.STABLE()
: isDev
? LL.DEVELOPMENT()
: 'Partition ' + LL.VERSION()}
</TableCell>
</TableRow>
{isPartition && (
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
Partition
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{partition}
</TableCell>
</TableRow>
)}
{isPartition && (
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
Size
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{size} KB
</TableCell>
</TableRow>
)}
{version?.published_at && (
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
<TableCell
component="th"
scope="row"
sx={{
color: 'lightblue',
borderBottom: 'none',
pr: 1,
py: 0.5,
fontSize: 13
}}
>
{isPartition ? 'Install Date' : 'Build Date'}
</TableCell>
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
{prettyDateTime(locale, new Date(version.published_at))}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</DialogContent>
<DialogActions>
{!isPartition && (
<Button
variant="outlined"
component="a"
href={relNotesUrl}
target="_blank"
color="primary"
>
Changelog
</Button>
)}
<Button variant="outlined" onClick={onClose} color="secondary">
{LL.CLOSE()}
</Button>
</DialogActions>
</Dialog>
);
}
);
const InstallDialog = memo(
({
openInstallDialog,
fetchDevVersion,
latestVersion,
latestDevVersion,
upgradeImportantMessageType,
downloadOnly,
platform,
LL,
onClose,
onInstall
}: {
openInstallDialog: boolean;
fetchDevVersion: boolean;
latestVersion?: VersionInfo;
latestDevVersion?: VersionInfo;
upgradeImportantMessageType: number;
downloadOnly: boolean;
platform: string;
LL: TranslationFunctions;
onClose: () => void;
onInstall: (url: string) => void;
}) => {
const binURL = useMemo(() => {
if (!latestVersion || !latestDevVersion) return '';
const version = fetchDevVersion ? latestDevVersion : latestVersion;
const filename = `EMS-ESP-${version.name.replaceAll('.', '_')}-${platform}.bin`;
return fetchDevVersion
? `${DEV_URL}${filename}`
: `${STABLE_URL}v${version.name}/${filename}`;
}, [fetchDevVersion, latestVersion, latestDevVersion, platform]);
return (
<Dialog sx={dialogStyle} open={openInstallDialog} onClose={onClose}>
<DialogTitle>
{`${LL.INSTALL()} ${fetchDevVersion ? LL.DEVELOPMENT() : LL.STABLE()} Firmware`}
</DialogTitle>
<DialogContent dividers>
<Typography sx={{ mb: 2 }}>
{LL.INSTALL_VERSION(
downloadOnly ? LL.DOWNLOAD(1) : LL.INSTALL(),
fetchDevVersion ? latestDevVersion?.name : latestVersion?.name
)}
</Typography>
{upgradeImportantMessageType === 1 && LL.UPGRADE_IMPORTANT_MESSAGES_1()}
{upgradeImportantMessageType === 2 && LL.UPGRADE_IMPORTANT_MESSAGES_2()}
<Typography sx={{ mt: 2 }}>
<Link
target="_blank"
to="https://docs.emsesp.org/FAQ#upgrading-the-firmware"
style={{ color: 'lightblue' }}
>
{LL.ONLINE_HELP()}
</Link>
</Typography>
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<DownloadIcon />}
variant="outlined"
onClick={onClose}
color="primary"
>
<Link
to={binURL}
target="_blank"
rel="noreferrer"
style={{ color: 'lightblue', textDecoration: 'none' }}
>
{LL.DOWNLOAD(0)}
</Link>
</Button>
{!downloadOnly && (
<Button
startIcon={<WarningIcon color="warning" />}
variant="outlined"
onClick={() => onInstall(binURL)}
color="primary"
>
{LL.INSTALL()}
</Button>
)}
</DialogActions>
</Dialog>
);
}
);
const InstallPartitionDialog = memo(
({
openInstallPartitionDialog,
version,
partition,
LL,
onClose,
onInstall
}: {
openInstallPartitionDialog: boolean;
version: string;
partition: string;
LL: TranslationFunctions;
onClose: () => void;
onInstall: (partition: string) => void;
}) => {
return (
<Dialog sx={dialogStyle} open={openInstallPartitionDialog} onClose={onClose}>
<DialogTitle>
{LL.INSTALL()} {LL.STORED_VERSIONS()}
</DialogTitle>
<DialogContent dividers>
<Typography sx={{ mb: 2 }}>
{LL.INSTALL_VERSION(LL.INSTALL(), version)}
</Typography>
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
variant="outlined"
onClick={() => onInstall(partition)}
color="primary"
>
{LL.INSTALL()}
</Button>
</DialogActions>
</Dialog>
);
}
);
// 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<boolean>(false);
const [openInstallDialog, setOpenInstallDialog] = useState<boolean>(false);
const [partitionVersion, setPartitionVersion] = useState<VersionInfo | undefined>(
undefined
);
const [partition, setPartition] = useState<string>('');
const [openInstallPartitionDialog, setOpenInstallPartitionDialog] =
useState<boolean>(false);
const [usingDevVersion, setUsingDevVersion] = useState<boolean>(false);
const [fetchDevVersion, setFetchDevVersion] = useState<boolean>(false);
const [devUpgradeAvailable, setDevUpgradeAvailable] = useState<boolean>(false);
const [stableUpgradeAvailable, setStableUpgradeAvailable] =
useState<boolean>(false);
const [internetLive, setInternetLive] = useState<boolean>(false);
const [downloadOnly, setDownloadOnly] = useState<boolean>(false);
const [showVersionInfo, setShowVersionInfo] = useState<number>(0); // 1 = stable, 2 = dev, 3 = partition
const [firmwareSize, setFirmwareSize] = useState<number>(0);
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 { send: sendSetPartition } = useRequest(
(partition: string) => callAction({ action: 'setPartition', param: partition }),
{ immediate: false }
).onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
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
});
const [upgradeImportantMessageType, setUpgradeImportantMessageType] =
useState<number>(0);
const { send: checkUpgradeImportantMessages } = useRequest(
(version: string) =>
callAction({ action: 'upgradeImportantMessages', param: version }),
{
immediate: false
}
)
.onSuccess((event) => {
const upgradeImportantMessageType_n = (
event.data as { upgradeImportantMessageType: number }
).upgradeImportantMessageType;
setUpgradeImportantMessageType(upgradeImportantMessageType_n);
})
.onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
// Memoized values
const platform = useMemo(() => (data ? getPlatform(data) : ''), [data]);
// Memoize filtered partitions to avoid recomputing on every render
const otherPartitions = useMemo(
() => data?.partitions.filter((p) => p.partition !== data.partition) ?? [],
[data]
);
const setPartitionVersionInfo = useCallback(
(partition: string) => {
setShowVersionInfo(3);
// search for the partition in the data.partitions array
const partitionData = data?.partitions.find((p) => p.partition === partition);
if (partitionData) {
setPartitionVersion({
name: partitionData.version,
published_at: partitionData.install_date ?? ''
});
setPartition(partitionData.partition);
setFirmwareSize(partitionData.size);
}
},
[data]
);
const doRestart = useCallback(async () => {
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
setRestarting(true);
}, [sendAPI]);
const installFirmwareURL = useCallback(
async (url: string) => {
await sendUploadURL(url).catch((error: Error) => {
toast.error(error.message);
});
await doRestart();
},
[sendUploadURL, doRestart]
);
const installPartitionFirmware = useCallback(
async (partition: string) => {
await sendSetPartition(partition).catch((error: Error) => {
toast.error(error.message);
});
setRestarting(true);
},
[sendSetPartition]
);
const showPartitionDialog = useCallback(
(version: string, partition: string, install_date: string) => {
setOpenInstallPartitionDialog(true);
setPartitionVersion({ name: version, published_at: install_date });
setPartition(partition);
},
[]
);
const showFirmwareDialog = useCallback(
(useDevVersion: boolean) => {
setFetchDevVersion(useDevVersion);
void checkUpgradeImportantMessages(
useDevVersion ? latestDevVersion?.name : latestVersion?.name
);
setOpenInstallDialog(true);
},
[latestDevVersion, latestVersion, fetchDevVersion]
);
const closeInstallDialog = useCallback(() => {
setOpenInstallDialog(false);
}, []);
const closeInstallPartitionDialog = useCallback(() => {
setOpenInstallPartitionDialog(false);
}, []);
const handleVersionInfoClose = useCallback(() => {
setShowVersionInfo(0);
setPartitionVersion(undefined);
setPartition('');
}, []);
// check upgrades - only once when both versions are available
const upgradeCheckedRef = useRef(false);
useEffect(() => {
if (latestVersion && latestDevVersion && !upgradeCheckedRef.current) {
upgradeCheckedRef.current = true;
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 (
<>
<CheckIcon
color="success"
sx={{ verticalAlign: 'middle', ml: 0.5, mr: 0.5 }}
/>
<span style={{ color: '#66bb6a', fontSize: '0.8em' }}>
{LL.LATEST_VERSION(usingDevVersion ? LL.DEVELOPMENT() : LL.STABLE())}
</span>
<Button
sx={{ ml: 1 }}
variant="outlined"
size="small"
onClick={() => showFirmwareDialog(showingDev)}
>
{LL.REINSTALL()}
</Button>
</>
);
}
if (!me.admin) return null;
return (
<Button
sx={{ ml: 1 }}
variant="outlined"
color={choice === LL.UPDATE_AVAILABLE() ? 'success' : 'warning'}
size="small"
onClick={() => showFirmwareDialog(showingDev)}
>
{choice}
</Button>
);
},
[
usingDevVersion,
devUpgradeAvailable,
stableUpgradeAvailable,
me.admin,
LL,
showFirmwareDialog
]
);
const content = useMemo(() => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
return (
<>
<Box sx={{ p: 2, border: '1px solid #565656', borderRadius: 2 }}>
<Typography sx={{ mb: 1 }} variant="h6" color="primary">
{LL.THIS_VERSION()}
</Typography>
<Grid
container
direction="row"
sx={{
justifyContent: 'flex-start',
alignItems: 'baseline'
}}
>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.VERSION()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{data.emsesp_version}
{data.build_flags && (
<Typography variant="caption">
&nbsp; &#40;{data.build_flags}&#41;
</Typography>
)}
<IconButton
onClick={() => setPartitionVersionInfo(data.partition)}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
</Typography>
</Grid>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.PLATFORM()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{platform}
<Typography variant="caption">
&nbsp; &#40;
{data.psram ? (
<CheckIcon
color="success"
sx={{
fontSize: '1.5em',
verticalAlign: 'middle'
}}
/>
) : (
<CloseIcon
color="error"
sx={{
fontSize: '1.5em',
verticalAlign: 'middle'
}}
/>
)}
PSRAM&#41;
</Typography>
</Typography>
</Grid>
</Grid>
{internetLive ? (
<>
<Typography sx={{ mt: 4, mb: 1 }} variant="h6" color="primary">
{LL.AVAILABLE_VERSION()}
</Typography>
<Grid
container
direction="row"
rowSpacing={1}
sx={{
justifyContent: 'flex-start',
alignItems: 'baseline'
}}
>
{otherPartitions.length > 0 && data.developer_mode && (
<>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">
{LL.STORED_VERSIONS()}
</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
{otherPartitions.map((partition) => (
<Typography key={partition.partition} sx={{ mb: 1 }}>
{partition.version}
<IconButton
onClick={() =>
setPartitionVersionInfo(partition.partition)
}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon
color="primary"
sx={{ fontSize: 18 }}
/>
</IconButton>
<Button
sx={{ ml: 0 }}
variant="outlined"
size="small"
onClick={() =>
showPartitionDialog(
partition.version,
partition.partition,
partition.install_date ?? ''
)
}
>
{LL.INSTALL()}
</Button>
</Typography>
))}
</Grid>
</>
)}
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.STABLE()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{latestVersion?.name}
<IconButton
onClick={() => setShowVersionInfo(1)}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
{showButtons(false)}
</Typography>
</Grid>
<Grid size={{ xs: 4, md: 2 }}>
<Typography color="secondary">{LL.DEVELOPMENT()}</Typography>
</Grid>
<Grid size={{ xs: 8, md: 10 }}>
<Typography>
{latestDevVersion?.name}
<IconButton
onClick={() => setShowVersionInfo(2)}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
{showButtons(true)}
</Typography>
</Grid>
</Grid>
</>
) : (
<Typography sx={{ mt: 2 }} color="warning">
<WarningIcon color="warning" sx={{ verticalAlign: 'middle', mr: 2 }} />
{LL.INTERNET_CONNECTION_REQUIRED()}
</Typography>
)}
{me.admin && (
<>
<VersionInfoDialog
showVersionInfo={showVersionInfo}
latestVersion={latestVersion}
latestDevVersion={latestDevVersion}
partitionVersion={partitionVersion}
locale={locale}
partition={partition}
currentPartition={data?.partition ?? ''}
size={firmwareSize}
LL={LL}
onClose={handleVersionInfoClose}
/>
<InstallDialog
openInstallDialog={openInstallDialog}
fetchDevVersion={fetchDevVersion}
latestVersion={latestVersion}
latestDevVersion={latestDevVersion}
upgradeImportantMessageType={upgradeImportantMessageType}
downloadOnly={downloadOnly}
platform={platform}
LL={LL}
onClose={closeInstallDialog}
onInstall={installFirmwareURL}
/>
<InstallPartitionDialog
openInstallPartitionDialog={openInstallPartitionDialog}
version={partitionVersion?.name || ''}
partition={partition}
LL={LL}
onClose={closeInstallPartitionDialog}
onInstall={installPartitionFirmware}
/>
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
{LL.UPLOAD()}
</Typography>
<SingleUpload doRestart={doRestart} />
</>
)}
</Box>
</>
);
}, [
data,
error,
loadData,
LL,
platform,
internetLive,
latestVersion,
latestDevVersion,
showVersionInfo,
locale,
openInstallDialog,
fetchDevVersion,
downloadOnly,
me.admin,
showButtons,
handleVersionInfoClose,
closeInstallDialog,
installFirmwareURL,
doRestart,
otherPartitions,
setPartitionVersionInfo,
showPartitionDialog,
partitionVersion,
partition,
firmwareSize,
closeInstallPartitionDialog,
installPartitionFirmware
]);
return restarting ? <SystemMonitor /> : <SectionContent>{content}</SectionContent>;
};
export default memo(Version);

View File

@@ -1,4 +1,4 @@
import { type FC, type PropsWithChildren, memo, useMemo } from 'react'; import { type FC, type PropsWithChildren, memo } from 'react';
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'; import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
import ErrorIcon from '@mui/icons-material/Error'; import ErrorIcon from '@mui/icons-material/Error';
@@ -38,18 +38,17 @@ const MessageBox: FC<PropsWithChildren<MessageBoxProps>> = ({
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const { Icon, backgroundColor } = useMemo(() => { const Icon = LEVEL_ICONS[level];
const Icon = LEVEL_ICONS[level]; const palettePath = LEVEL_PALETTE_PATHS[level];
const palettePath = LEVEL_PALETTE_PATHS[level]; const [paletteKeyName, shade] = palettePath.split('.') as [
const [key, shade] = palettePath.split('.') as [ keyof typeof theme.palette,
keyof typeof theme.palette, string
string ];
]; const paletteKey = theme.palette[paletteKeyName] as unknown as Record<
const paletteKey = theme.palette[key] as unknown as Record<string, string>; string,
const backgroundColor = paletteKey[shade]; string
>;
return { Icon, backgroundColor }; const backgroundColor = paletteKey[shade];
}, [level, theme]);
return ( return (
<Box <Box

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useContext, useMemo } from 'react'; import { memo, useContext } from 'react';
import type { ChangeEventHandler } from 'react'; import type { ChangeEventHandler } from 'react';
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
@@ -44,27 +44,14 @@ const LANGUAGE_OPTIONS: LanguageOption[] = [
const LanguageSelector = () => { const LanguageSelector = () => {
const { setLocale, locale, LL } = useContext(I18nContext); const { setLocale, locale, LL } = useContext(I18nContext);
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = useCallback( const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = async ({
async ({ target }) => { target
const loc = target.value as Locales; }) => {
localStorage.setItem('lang', loc); const loc = target.value as Locales;
await loadLocaleAsync(loc); localStorage.setItem('lang', loc);
setLocale(loc); await loadLocaleAsync(loc);
}, setLocale(loc);
[setLocale] };
);
// Memoize menu items to prevent recreation on every render
const menuItems = useMemo(
() =>
LANGUAGE_OPTIONS.map(({ key, flag, label }) => (
<MenuItem key={key} value={key}>
<img src={flag} style={flagStyle} alt={label} />
&nbsp;{label}
</MenuItem>
)),
[]
);
return ( return (
<TextField <TextField
@@ -76,7 +63,12 @@ const LanguageSelector = () => {
size="small" size="small"
select select
> >
{menuItems} {LANGUAGE_OPTIONS.map(({ key, flag, label }) => (
<MenuItem key={key} value={key}>
<img src={flag} style={flagStyle} alt={label} />
&nbsp;{label}
</MenuItem>
))}
</TextField> </TextField>
); );
}; };

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useState } from 'react'; import { memo, useState } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import VisibilityIcon from '@mui/icons-material/Visibility'; import VisibilityIcon from '@mui/icons-material/Visibility';
@@ -13,9 +13,9 @@ type ValidatedPasswordFieldProps = Omit<ValidatedTextFieldProps, 'type'>;
const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) => { const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) => {
const [showPassword, setShowPassword] = useState<boolean>(false); const [showPassword, setShowPassword] = useState<boolean>(false);
const togglePasswordVisibility = useCallback(() => { const togglePasswordVisibility = () => {
setShowPassword((prev) => !prev); setShowPassword((prev) => !prev);
}, []); };
return ( return (
<ValidatedTextField <ValidatedTextField

View File

@@ -18,7 +18,6 @@ const LayoutComponent: FC<RequiredChildrenProps> = ({ children }) => {
const [title, setTitle] = useState(PROJECT_NAME); const [title, setTitle] = useState(PROJECT_NAME);
const { pathname } = useLocation(); const { pathname } = useLocation();
// Memoize drawer toggle handler to prevent unnecessary re-renders
const handleDrawerToggle = useCallback(() => { const handleDrawerToggle = useCallback(() => {
setMobileOpen((prev) => !prev); setMobileOpen((prev) => !prev);
}, []); }, []);
@@ -28,7 +27,6 @@ const LayoutComponent: FC<RequiredChildrenProps> = ({ children }) => {
setMobileOpen(false); setMobileOpen(false);
}, [pathname]); }, [pathname]);
// Memoize context value to prevent unnecessary re-renders
const contextValue = useMemo(() => ({ title, setTitle }), [title]); const contextValue = useMemo(() => ({ title, setTitle }), [title]);
return ( return (

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useMemo } from 'react'; import { memo } from 'react';
import { Link, useLocation, useNavigate } from 'react-router'; import { Link, useLocation, useNavigate } from 'react-router';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
@@ -39,14 +39,11 @@ const LayoutAppBarComponent = ({ title, onToggleDrawer }: LayoutAppBarProps) =>
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const pathnames = useMemo( const pathnames = location.pathname.split('/').filter((x) => x);
() => location.pathname.split('/').filter((x) => x),
[location.pathname]
);
const handleBackClick = useCallback(() => { const handleBackClick = () => {
void navigate('/' + pathnames[0]); void navigate('/' + pathnames[0]);
}, [navigate, pathnames]); };
return ( return (
<AppBar position="fixed" sx={appBarStyles}> <AppBar position="fixed" sx={appBarStyles}>

View File

@@ -1,4 +1,4 @@
import { memo, useMemo } from 'react'; import { memo } from 'react';
import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material'; import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material';
@@ -24,22 +24,18 @@ interface LayoutDrawerProps {
} }
const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => { const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
// Memoize drawer content to prevent unnecessary re-renders const drawer = (
const drawer = useMemo( <>
() => ( <Toolbar disableGutters>
<> <Box sx={{ display: 'flex', alignItems: 'center', p: 2 }}>
<Toolbar disableGutters> <LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} />
<Box sx={{ display: 'flex', alignItems: 'center', p: 2 }}> <Typography variant="h6">{PROJECT_NAME}</Typography>
<LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} /> </Box>
<Typography variant="h6">{PROJECT_NAME}</Typography> <Divider absolute />
</Box> </Toolbar>
<Divider absolute /> <Divider />
</Toolbar> <LayoutMenu />
<Divider /> </>
<LayoutMenu />
</>
),
[]
); );
return ( return (

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useContext, useState } from 'react'; import { memo, useContext, useState } from 'react';
import AccountCircleIcon from '@mui/icons-material/AccountCircle'; import AccountCircleIcon from '@mui/icons-material/AccountCircle';
import AssessmentIcon from '@mui/icons-material/Assessment'; import AssessmentIcon from '@mui/icons-material/Assessment';
@@ -18,13 +18,15 @@ import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
const LayoutMenuComponent = () => { const LayoutMenuComponent = () => {
const { me } = useContext(AuthenticatedContext); const { me, versions } = useContext(AuthenticatedContext);
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [menuOpen, setMenuOpen] = useState(true); const [menuOpen, setMenuOpen] = useState(true);
const handleMenuToggle = useCallback(() => { const upgradeAvailable = versions?.current?.upgradeable ?? false;
const handleMenuToggle = () => {
setMenuOpen((prev) => !prev); setMenuOpen((prev) => !prev);
}, []); };
return ( return (
<> <>
@@ -105,6 +107,7 @@ const LayoutMenuComponent = () => {
label={LL.SETTINGS(0)} label={LL.SETTINGS(0)}
disabled={!me.admin} disabled={!me.admin}
to="/settings" to="/settings"
badge={upgradeAvailable}
/> />
<LayoutMenuItem icon={LiveHelpIcon} label={LL.HELP()} to={`/help`} /> <LayoutMenuItem icon={LiveHelpIcon} label={LL.HELP()} to={`/help`} />
<Divider /> <Divider />

View File

@@ -1,7 +1,7 @@
import { memo, useMemo } from 'react'; import { memo } from 'react';
import { Link, useLocation } from 'react-router'; import { Link, useLocation } from 'react-router';
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material'; import { Box, ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
import type { SvgIconProps, SxProps, Theme } from '@mui/material'; import type { SvgIconProps, SxProps, Theme } from '@mui/material';
import { routeMatches } from 'utils'; import { routeMatches } from 'utils';
@@ -11,60 +11,52 @@ interface LayoutMenuItemProps {
label: string; label: string;
to: string; to: string;
disabled?: boolean; disabled?: boolean;
badge?: boolean;
} }
const LayoutMenuItemComponent = ({ const LayoutMenuItemComponent = ({
icon: Icon, icon: Icon,
label, label,
to, to,
disabled disabled,
badge
}: LayoutMenuItemProps) => { }: LayoutMenuItemProps) => {
const { pathname } = useLocation(); const { pathname } = useLocation();
const selected = useMemo(() => routeMatches(to, pathname), [to, pathname]); const selected = routeMatches(to, pathname);
// Memoize dynamic styles based on selected state const buttonStyles: SxProps<Theme> = {
const buttonStyles: SxProps<Theme> = useMemo( transition: 'all 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
() => ({ backgroundColor: selected ? 'rgba(144, 202, 249, 0.1)' : 'transparent',
transition: 'all 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', borderRadius: '8px',
backgroundColor: selected ? 'rgba(144, 202, 249, 0.1)' : 'transparent', margin: '2px 8px',
borderRadius: '8px', '&:hover': {
margin: '2px 8px', backgroundColor: 'rgba(68, 82, 211, 0.39)'
'&:hover': { },
backgroundColor: 'rgba(68, 82, 211, 0.39)' '&::before': {
}, content: '""',
'&::before': { position: 'absolute',
content: '""', left: 0,
position: 'absolute', top: 0,
left: 0, bottom: 0,
top: 0, width: selected ? '3px' : '0px',
bottom: 0, backgroundColor: '#90caf9',
width: selected ? '3px' : '0px', transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)'
backgroundColor: '#90caf9', }
transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)' };
}
}),
[selected]
);
const iconStyles: SxProps<Theme> = useMemo( const iconStyles: SxProps<Theme> = {
() => ({ color: selected ? '#90caf9' : '#9e9e9e',
color: selected ? '#90caf9' : '#9e9e9e', transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', transform: selected ? 'scale(1.1)' : 'scale(1)',
transform: selected ? 'scale(1.1)' : 'scale(1)', transitionProperty: 'color, transform'
transitionProperty: 'color, transform' };
}),
[selected]
);
const textStyles: SxProps<Theme> = useMemo( const textStyles: SxProps<Theme> = {
() => ({ color: selected ? '#90caf9' : '#f5f5f5',
color: selected ? '#90caf9' : '#f5f5f5', transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', transitionProperty: 'color, font-weight'
transitionProperty: 'color, font-weight' };
}),
[selected]
);
return ( return (
<ListItemButton <ListItemButton
@@ -78,6 +70,20 @@ const LayoutMenuItemComponent = ({
<Icon /> <Icon />
</ListItemIcon> </ListItemIcon>
<ListItemText sx={textStyles}>{label}</ListItemText> <ListItemText sx={textStyles}>{label}</ListItemText>
{badge && (
<Box
aria-label="update available"
sx={{
width: 8,
height: 8,
ml: 1,
borderRadius: '50%',
backgroundColor: '#ffeb3b',
boxShadow: '0 0 6px rgba(255, 235, 59, 0.8)',
flexShrink: 0
}}
/>
)}
</ListItemButton> </ListItemButton>
); );
}; };

View File

@@ -5,6 +5,7 @@ import { Link } from 'react-router';
import NavigateNextIcon from '@mui/icons-material/NavigateNext'; import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import { import {
Avatar, Avatar,
Box,
ListItem, ListItem,
ListItemAvatar, ListItemAvatar,
ListItemButton, ListItemButton,
@@ -20,6 +21,7 @@ interface ListMenuItemProps {
text: string; text: string;
to?: string; to?: string;
disabled?: boolean; disabled?: boolean;
badge?: boolean;
} }
const iconStyles: CSSProperties = { const iconStyles: CSSProperties = {
@@ -28,15 +30,40 @@ const iconStyles: CSSProperties = {
verticalAlign: 'middle' verticalAlign: 'middle'
}; };
const Badge = () => (
<Box
component="span"
aria-label="update available"
sx={{
display: 'inline-block',
width: 8,
height: 8,
ml: 1,
verticalAlign: 'middle',
borderRadius: '50%',
backgroundColor: '#ffeb3b',
boxShadow: '0 0 6px rgba(255, 235, 59, 0.8)'
}}
/>
);
const RenderIcon = memo( const RenderIcon = memo(
({ icon: Icon, bgcolor, label, text }: ListMenuItemProps) => ( ({ icon: Icon, bgcolor, label, text, badge }: ListMenuItemProps) => (
<> <>
<ListItemAvatar> <ListItemAvatar>
<Avatar sx={{ bgcolor, color: 'white' }}> <Avatar sx={{ bgcolor, color: 'white' }}>
<Icon /> <Icon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary={label} secondary={text} /> <ListItemText
primary={
<>
{label}
{badge && <Badge />}
</>
}
secondary={text}
/>
</> </>
) )
); );
@@ -47,7 +74,8 @@ const LayoutMenuItem = ({
label, label,
text, text,
to, to,
disabled disabled,
badge
}: ListMenuItemProps) => ( }: ListMenuItemProps) => (
<> <>
{to && !disabled ? ( {to && !disabled ? (
@@ -65,6 +93,7 @@ const LayoutMenuItem = ({
{...(bgcolor && { bgcolor })} {...(bgcolor && { bgcolor })}
label={label} label={label}
text={text} text={text}
{...(badge && { badge })}
/> />
</ListItemButton> </ListItemButton>
</ListItem> </ListItem>
@@ -75,6 +104,7 @@ const LayoutMenuItem = ({
{...(bgcolor && { bgcolor })} {...(bgcolor && { bgcolor })}
label={label} label={label}
text={text} text={text}
{...(badge && { badge })}
/> />
</ListItem> </ListItem>
)} )}

View File

@@ -1,4 +1,4 @@
import { memo, useCallback } from 'react'; import { memo } from 'react';
import type { Blocker } from 'react-router'; import type { Blocker } from 'react-router';
import { import {
@@ -15,13 +15,13 @@ import { useI18nContext } from 'i18n/i18n-react';
const BlockNavigation = ({ blocker }: { blocker: Blocker }) => { const BlockNavigation = ({ blocker }: { blocker: Blocker }) => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const handleReset = useCallback(() => { const handleReset = () => {
blocker.reset?.(); blocker.reset?.();
}, [blocker]); };
const handleProceed = useCallback(() => { const handleProceed = () => {
blocker.proceed?.(); blocker.proceed?.();
}, [blocker]); };
return ( return (
<Dialog sx={dialogStyle} open={blocker.state === 'blocked'}> <Dialog sx={dialogStyle} open={blocker.state === 'blocked'}>

View File

@@ -1,4 +1,4 @@
import { memo, useCallback } from 'react'; import { memo } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
@@ -16,12 +16,9 @@ const RouterTabs: FC<RouterTabsProps> = ({ value, children }) => {
const theme = useTheme(); const theme = useTheme();
const smallDown = useMediaQuery(theme.breakpoints.down('sm')); const smallDown = useMediaQuery(theme.breakpoints.down('sm'));
const handleTabChange = useCallback( const handleTabChange = (_event: unknown, path: string) => {
(_event: unknown, path: string) => { void navigate(path);
void navigate(path); };
},
[navigate]
);
return ( return (
<Tabs <Tabs

View File

@@ -91,7 +91,9 @@ const DragNdrop = ({ text, onFileSelected }: DragNdropProps) => {
).upgradeImportantMessageType; ).upgradeImportantMessageType;
setUpgradeImportantMessageType(upgradeImportantMessageType_n); setUpgradeImportantMessageType(upgradeImportantMessageType_n);
if (upgradeImportantMessageType_n === 0) { if (upgradeImportantMessageType_n === 0) {
onFileSelected(file); if (file) {
onFileSelected(file);
}
} }
}) })
.onError((error) => { .onError((error) => {

View File

@@ -3,6 +3,7 @@ import type { FC } from 'react';
import { redirect } from 'react-router'; import { redirect } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { callAction } from 'api/app';
import { ACCESS_TOKEN } from 'api/endpoints'; import { ACCESS_TOKEN } from 'api/endpoints';
import * as AuthenticationApi from 'components/routing/authentication'; import * as AuthenticationApi from 'components/routing/authentication';
@@ -10,7 +11,7 @@ import { useRequest } from 'alova/client';
import { LoadingSpinner } from 'components'; import { LoadingSpinner } from 'components';
import { verifyAuthorization } from 'components/routing/authentication'; import { verifyAuthorization } from 'components/routing/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import type { Me } from 'types'; import type { Me, VersionsResponse } from 'types';
import type { RequiredChildrenProps } from 'utils'; import type { RequiredChildrenProps } from 'utils';
import { AuthenticationContext } from './context'; import { AuthenticationContext } from './context';
@@ -20,17 +21,34 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
const [initialized, setInitialized] = useState<boolean>(false); const [initialized, setInitialized] = useState<boolean>(false);
const [me, setMe] = useState<Me>(); const [me, setMe] = useState<Me>();
const [versions, setVersions] = useState<VersionsResponse>();
const { send: sendVerifyAuthorization } = useRequest(verifyAuthorization(), { const { send: sendVerifyAuthorization } = useRequest(verifyAuthorization(), {
immediate: false immediate: false
}); });
const { send: sendGetVersions } = useRequest(
() => callAction({ action: 'getVersions' }),
{ immediate: false }
)
.onSuccess((event) => {
setVersions(event.data as VersionsResponse);
})
.onError(() => {
setVersions(undefined);
});
const refreshVersions = useCallback(async () => {
await sendGetVersions().catch(() => undefined);
}, []);
const signIn = (accessToken: string) => { const signIn = (accessToken: string) => {
try { try {
AuthenticationApi.getStorage().setItem(ACCESS_TOKEN, accessToken); AuthenticationApi.getStorage().setItem(ACCESS_TOKEN, accessToken);
const decodedMe = AuthenticationApi.decodeMeJWT(accessToken); const decodedMe = AuthenticationApi.decodeMeJWT(accessToken);
setMe(decodedMe); setMe(decodedMe);
toast.success(LL.LOGGED_IN({ name: decodedMe.username })); toast.success(LL.LOGGED_IN({ name: decodedMe.username }));
void refreshVersions();
} catch { } catch {
setMe(undefined); setMe(undefined);
throw new Error('Failed to parse JWT'); throw new Error('Failed to parse JWT');
@@ -40,6 +58,7 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
const signOut = (doRedirect: boolean) => { const signOut = (doRedirect: boolean) => {
AuthenticationApi.clearAccessToken(); AuthenticationApi.clearAccessToken();
setMe(undefined); setMe(undefined);
setVersions(undefined);
if (doRedirect) { if (doRedirect) {
redirect('/'); redirect('/');
} }
@@ -49,8 +68,9 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
const accessToken = AuthenticationApi.getStorage().getItem(ACCESS_TOKEN); const accessToken = AuthenticationApi.getStorage().getItem(ACCESS_TOKEN);
if (accessToken) { if (accessToken) {
await sendVerifyAuthorization() await sendVerifyAuthorization()
.then(() => { .then(async () => {
setMe(AuthenticationApi.decodeMeJWT(accessToken)); setMe(AuthenticationApi.decodeMeJWT(accessToken));
await refreshVersions();
setInitialized(true); setInitialized(true);
}) })
.catch(() => { .catch(() => {
@@ -61,21 +81,24 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
setMe(undefined); setMe(undefined);
setInitialized(true); setInitialized(true);
} }
// refreshVersions and sendVerifyAuthorization are stable
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
useEffect(() => { useEffect(() => {
void refresh(); void refresh();
}, [refresh]); }, [refresh]);
// cache object to prevent re-renders
const obj = useMemo( const obj = useMemo(
() => ({ () => ({
signIn, signIn,
signOut, signOut,
refresh, refresh,
...(me && { me }) refreshVersions,
...(me && { me }),
...(versions && { versions })
}), }),
[signIn, signOut, me, refresh] [signIn, signOut, me, refresh, refreshVersions, versions]
); );
if (initialized) { if (initialized) {

View File

@@ -1,12 +1,14 @@
import { createContext } from 'react'; import { createContext } from 'react';
import type { Me } from 'types'; import type { Me, VersionsResponse } from 'types';
export interface AuthenticationContextValue { export interface AuthenticationContextValue {
refresh: () => Promise<void>; refresh: () => Promise<void>;
signIn: (accessToken: string) => void; signIn: (accessToken: string) => void;
signOut: (redirect: boolean) => void; signOut: (redirect: boolean) => void;
me?: Me; me?: Me;
versions?: VersionsResponse;
refreshVersions: () => Promise<void>;
} }
const AuthenticationContextDefaultValue = {} as AuthenticationContextValue; const AuthenticationContextDefaultValue = {} as AuthenticationContextValue;

View File

@@ -7,3 +7,4 @@ export * from './ntp';
export * from './security'; export * from './security';
export * from './signin'; export * from './signin';
export * from './system'; export * from './system';
export * from './versions';

View File

@@ -0,0 +1,23 @@
// Types for the `getVersions` action response coming from the device.
// The device proxies the request to emsesp.org/versions.json. If the device
// is offline the `stable` and `dev` fields are omitted.
export interface VersionInfo {
version: string;
date: string;
}
export interface RemoteVersionInfo extends VersionInfo {
upgradeable: boolean;
}
export interface CurrentVersionInfo extends VersionInfo {
type: 'stable' | 'dev';
upgradeable: boolean;
}
export interface VersionsResponse {
current: CurrentVersionInfo;
stable?: RemoteVersionInfo;
dev?: RemoteVersionInfo;
}

View File

@@ -1,34 +1,27 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
export const usePersistState = <T>( export const usePersistState = <T>(
initial_value: T, initial_value: T,
id: string id: string
): [T, (new_state: T) => void] => { ): [T, (new_state: T) => void] => {
// Set initial value - only computed once on mount const [state, setState] = useState<T>(() => {
const _initial_value = useMemo(() => {
try { try {
const local_storage_value_str = localStorage.getItem(`state:${id}`); const stored = localStorage.getItem(`state:${id}`);
// If there is a value stored in localStorage, use that if (stored) {
if (local_storage_value_str) { return JSON.parse(stored) as T;
return JSON.parse(local_storage_value_str) as T;
} }
} catch (error) { } catch (error) {
// If parsing fails, fall back to initial_value
console.warn( console.warn(
`Failed to parse localStorage value for key "state:${id}"`, `Failed to parse localStorage value for key "state:${id}"`,
error error
); );
} }
// Otherwise use initial_value that was passed to the function
return initial_value; return initial_value;
}, [id]); // initial_value intentionally omitted - only read on first mount });
const [state, setState] = useState(_initial_value);
useEffect(() => { useEffect(() => {
try { try {
const state_str = JSON.stringify(state); localStorage.setItem(`state:${id}`, JSON.stringify(state));
localStorage.setItem(`state:${id}`, state_str);
} catch (error) { } catch (error) {
console.warn( console.warn(
`Failed to save state to localStorage for key "state:${id}"`, `Failed to save state to localStorage for key "state:${id}"`,

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useState } from 'react';
import { useBlocker } from 'react-router'; import { useBlocker } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -54,61 +54,44 @@ export const useRest = <D>({ read, update }: RestRequestOptions<D>) => {
} }
}, [readData]); }, [readData]);
const saveData = useCallback(async () => { const saveData = async () => {
if (!data) return; if (!data) return;
// Reset states before saving
setRestartNeeded(false); setRestartNeeded(false);
setErrorMessage(undefined); setErrorMessage(undefined);
try { try {
await writeData(data as D); await writeData(data as D);
// Only update origData on successful save (dirtyFlags cleared by onSuccess handler)
setOrigData(data as D); setOrigData(data as D);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
if (message === REBOOT_ERROR_MESSAGE) { if (message === REBOOT_ERROR_MESSAGE) {
setRestartNeeded(true); setRestartNeeded(true);
return; // Early return - save succeeded but needs reboot return;
} }
// Restore original data on validation error
if (origData) { if (origData) {
updateData({ data: origData }); updateData({ data: origData });
} }
toast.error(message); toast.error(message);
setErrorMessage(message); setErrorMessage(message);
setDirtyFlags([]); // Clear flags so user can retry setDirtyFlags([]);
} }
}, [data, writeData, origData, updateData]); };
return useMemo( return {
() => ({ loadData,
loadData, saveData,
saveData, saving: !!saving,
saving: !!saving, updateDataValue,
updateDataValue, data: data as D,
data: data as D, origData: origData as D,
origData: origData as D, dirtyFlags,
dirtyFlags, setDirtyFlags,
setDirtyFlags, setOrigData,
setOrigData, blocker,
blocker, errorMessage,
errorMessage, restartNeeded
restartNeeded };
}),
[
loadData,
saveData,
saving,
updateDataValue,
data,
origData,
dirtyFlags,
blocker,
errorMessage,
restartNeeded
]
);
}; };

View File

@@ -6,9 +6,6 @@ import { Plugin, PluginOption, defineConfig } from 'vite';
import viteImagemin from 'vite-plugin-imagemin'; import viteImagemin from 'vite-plugin-imagemin';
import zlib from 'zlib'; import zlib from 'zlib';
// @ts-expect-error - mock server doesn't have type declarations
import mockServer from '../mock-api/mockServer.js';
// Constants // Constants
const KB_DIVISOR = 1024; const KB_DIVISOR = 1024;
const REPEAT_CHAR = '='; const REPEAT_CHAR = '=';
@@ -100,6 +97,10 @@ const createPreactPlugin = (devToolsEnabled: boolean) =>
// Patch preact/compat to export stub React 19 APIs (use, useOptimistic) so that // Patch preact/compat to export stub React 19 APIs (use, useOptimistic) so that
// react-router v7 doesn't trigger IMPORT_IS_UNDEFINED warnings from Rolldown. // react-router v7 doesn't trigger IMPORT_IS_UNDEFINED warnings from Rolldown.
// Rolldown tracks the constant strings used in `React[REACT_USE]` /
// `React[USE_OPTIMISTIC]` lookups inside react-router and resolves them
// statically, so simply relying on a runtime guard is not enough — we need
// matching (stub) exports on the aliased preact/compat module.
const preactCompatPatchPlugin = (): Plugin => ({ const preactCompatPatchPlugin = (): Plugin => ({
name: 'preact-compat-react19-patch', name: 'preact-compat-react19-patch',
transform(code, id) { transform(code, id) {
@@ -210,9 +211,11 @@ const imageOptimizationPlugin = {
}; };
export default defineConfig( export default defineConfig(
({ command, mode }: { command: string; mode: string }) => { async ({ command, mode }: { command: string; mode: string }) => {
if (command === 'serve') { if (command === 'serve') {
console.log(`Preparing for standalone build with server, mode=${mode}`); console.log(`Preparing for standalone build with server, mode=${mode}`);
// @ts-expect-error - mock server doesn't have type declarations
const { default: mockServer } = await import('../mock-api/mockServer.js');
return { return {
plugins: [...createBasePlugins(true, true), mockServer()], plugins: [...createBasePlugins(true, true), mockServer()],
resolve: { resolve: {
@@ -229,8 +232,7 @@ export default defineConfig(
changeOrigin: true, changeOrigin: true,
secure: false secure: false
}, },
'/rest': 'http://localhost:3080', '/rest': 'http://localhost:3080'
'/gh': 'http://localhost:3080'
} }
}, },
build: { build: {

View File

@@ -15,5 +15,5 @@
"itty-router": "^5.0.23", "itty-router": "^5.0.23",
"prettier": "^3.8.3" "prettier": "^3.8.3"
}, },
"packageManager": "pnpm@10.33.1+sha512.05ba3c1d5d1c18f68df06470d74055e62d41fc110a0c660db1b2dfb2785327f04cf0f68345d4609bc52089e7fa0343c31593b2f9594e2c5d5da426230acc9820" "packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8"
} }

View File

@@ -6,7 +6,6 @@ const router = AutoRouter();
const REST_ENDPOINT_ROOT = '/rest/'; const REST_ENDPOINT_ROOT = '/rest/';
const API_ENDPOINT_ROOT = '/api/'; const API_ENDPOINT_ROOT = '/api/';
const GH_ENDPOINT_ROOT = '/gh/'; // for mock GitHub API for version checking
// HTTP HEADERS for msgpack // HTTP HEADERS for msgpack
const headers = { const headers = {
@@ -128,7 +127,7 @@ let system_status = {
} }
], ],
// partitions: [], // partitions: [],
developer_mode: true, developer_mode: settings.developer_mode,
model: '', model: '',
board: '', board: '',
// model: 'BBQKees Electronics EMS Gateway E32 V2 (E32 V2.0 P3/2024011)', // model: 'BBQKees Electronics EMS Gateway E32 V2 (E32 V2.0 P3/2024011)',
@@ -142,13 +141,13 @@ let DEV_VERSION_IS_UPGRADEABLE: boolean;
let STABLE_VERSION_IS_UPGRADEABLE: boolean; let STABLE_VERSION_IS_UPGRADEABLE: boolean;
let THIS_VERSION: string; let THIS_VERSION: string;
let LATEST_STABLE_VERSION = '3.8.2'; let LATEST_STABLE_VERSION = '3.8.2';
let LATEST_DEV_VERSION = '3.8.3-dev.2'; let LATEST_DEV_VERSION = '3.9.0-dev.1';
// scenarios for testing versioning // scenarios for testing versioning
let version_test = 0; // on latest stable, or switch to dev // let version_test = 0; // on latest stable, or switch to dev
// let version_test = 1; // on latest dev, or switch back to stable // let version_test = 1; // on latest dev, or switch back to stable
// let version_test = 2; // upgrade an older stable to latest stable or switch to latest dev // let version_test = 2; // upgrade an older stable to latest stable or switch to latest dev
// let version_test = 3; // upgrade dev to latest, or switch to stable let version_test = 3; // upgrade dev to latest, or switch to stable
// let version_test = 4; // downgrade to an older dev, or switch back to stable // let version_test = 4; // downgrade to an older dev, or switch back to stable
switch (version_test as number) { switch (version_test as number) {
@@ -415,36 +414,60 @@ function upgradeImportantMessages(version: string) {
return { upgradeImportantMessageType: upgradeImportantMessageType_n }; return { upgradeImportantMessageType: upgradeImportantMessageType_n };
} }
// called by Action endpoint checkUpgrade // called by Action endpoint getVersions
function check_upgrade(version: string) { // Set MOCK_OFFLINE = true to simulate a device with no internet (omits stable/dev).
let data = {}; const MOCK_OFFLINE = false;
if (version) { function get_versions() {
const dev_version = version.split(',')[0]; const isDev = THIS_VERSION.includes('dev');
const stable_version = version.split(',')[1]; const currentUpgradeable =
!MOCK_OFFLINE &&
(isDev ? DEV_VERSION_IS_UPGRADEABLE : STABLE_VERSION_IS_UPGRADEABLE);
console.log( const data: {
'Upgrade this version (' + current: {
THIS_VERSION + version: string;
') to dev (' + type: 'stable' | 'dev';
dev_version + date: string;
') is ' + upgradeable: boolean;
(DEV_VERSION_IS_UPGRADEABLE ? 'YES' : 'NO') +
' and to stable (' +
stable_version +
') is ' +
(STABLE_VERSION_IS_UPGRADEABLE ? 'YES' : 'NO')
);
data = {
emsesp_version: THIS_VERSION,
dev_upgradeable: DEV_VERSION_IS_UPGRADEABLE,
stable_upgradeable: STABLE_VERSION_IS_UPGRADEABLE
}; };
} else { stable?: { version: string; date: string; upgradeable: boolean };
console.log('requesting ems-esp version (' + THIS_VERSION + ')'); dev?: { version: string; date: string; upgradeable: boolean };
data = { } = {
emsesp_version: THIS_VERSION current: {
version: THIS_VERSION,
type: isDev ? 'dev' : 'stable',
date: '2026-04-25T12:00:00',
upgradeable: currentUpgradeable
}
};
if (!MOCK_OFFLINE) {
data.stable = {
version: LATEST_STABLE_VERSION,
date: '2026-04-25',
upgradeable: STABLE_VERSION_IS_UPGRADEABLE
};
data.dev = {
version: LATEST_DEV_VERSION,
date: '2026-04-25',
upgradeable: DEV_VERSION_IS_UPGRADEABLE
}; };
} }
console.log(
'getVersions: current=' +
THIS_VERSION +
' stable=' +
LATEST_STABLE_VERSION +
' (upgradeable=' +
(STABLE_VERSION_IS_UPGRADEABLE ? 'YES' : 'NO') +
') dev=' +
LATEST_DEV_VERSION +
' (upgradeable=' +
(DEV_VERSION_IS_UPGRADEABLE ? 'YES' : 'NO') +
')' +
(MOCK_OFFLINE ? ' [offline]' : '')
);
return data; return data;
} }
@@ -4579,6 +4602,7 @@ router
.post(EMSESP_SETTINGS_ENDPOINT, async (request: any) => { .post(EMSESP_SETTINGS_ENDPOINT, async (request: any) => {
settings = await request.json(); settings = await request.json();
console.log('application settings saved', settings); console.log('application settings saved', settings);
system_status.developer_mode = settings.developer_mode;
return status(200); // no restart needed return status(200); // no restart needed
// return status(205); // reboot required // return status(205); // reboot required
}) })
@@ -5172,13 +5196,9 @@ router
} else if (action === 'getCustomSupport') { } else if (action === 'getCustomSupport') {
// send custom support // send custom support
return custom_support(); return custom_support();
} else if (action === 'checkUpgrade') { } else if (action === 'getVersions') {
// check upgrade // get versions
// check if content has a param return get_versions();
if (!content.param) {
return check_upgrade('');
}
return check_upgrade(content.param);
} else if (action === 'uploadURL') { } else if (action === 'uploadURL') {
// upload URL // upload URL
console.log('upload File from URL', content.param); console.log('upload File from URL', content.param);
@@ -5233,27 +5253,6 @@ router
return status(404); // not found return status(404); // not found
}); });
// Mock GitHub API
// https://api.github.com/repos/emsesp/EMS-ESP32/releases
router
.get(GH_ENDPOINT_ROOT + '/tags/latest', () => {
const data = {
name: 'v' + LATEST_DEV_VERSION,
published_at: new Date().toISOString() // use todays date
};
console.log('returning latest development version (today): ', data);
return data;
})
.get(GH_ENDPOINT_ROOT + '/latest', () => {
const data = {
name: 'v' + LATEST_STABLE_VERSION,
published_at: '2025-03-01T13:29:13.999Z'
};
console.log('returning latest stable version: ', data);
return data;
});
// const logger: ResponseHandler = (response, request) => { // const logger: ResponseHandler = (response, request) => {
// console.log( // console.log(
// response.status, // response.status,

View File

@@ -91,7 +91,7 @@ board_build.filesystem = littlefs
lib_deps = lib_deps =
bblanchon/ArduinoJson @ 7.4.3 bblanchon/ArduinoJson @ 7.4.3
ESP32Async/AsyncTCP @ 3.4.10 ESP32Async/AsyncTCP @ 3.4.10
ESP32Async/ESPAsyncWebServer @ 3.10.3 ESP32Async/ESPAsyncWebServer @ 3.11.0
https://github.com/mobizt/ReadyMail.git @ 0.4.0 https://github.com/mobizt/ReadyMail.git @ 0.4.0
https://github.com/mobizt/ESP_SSLClient.git @ 3.1.3 https://github.com/mobizt/ESP_SSLClient.git @ 3.1.3
; https://github.com/emsesp/EMS-ESP-Modules.git @ 1.0.8 ; https://github.com/emsesp/EMS-ESP-Modules.git @ 1.0.8

View File

@@ -1,126 +0,0 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2026 emsesp.org
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef EMSESP_Version_H
#define EMSESP_Version_H
#include <cstdio>
#include <cstring>
#include <string>
// Drop-in lightweight replacement for the subset of the semver library actually used by EMS-ESP.
// The previous semver library (lib/semver) builds a std::map + std::function-based state machine on
// every parse, which fragments the internal heap on the ESP32. This replacement does no heap
// allocation beyond the std::string member for the prerelease tag, and matches the API surface
// we consume: construction from string, major()/minor()/patch()/prerelease(), and operator>/</==.
//
// Only strict numeric precedence (major.minor.patch) is used for comparison in EMS-ESP, so we
// intentionally ignore prerelease tags during comparison rather than implement the full semver
// ordering rules. This is consistent with how the old code was used (callers only check major/
// minor/patch numerically; prerelease() is only read for logging).
namespace version {
class EMSESP_Version {
public:
EMSESP_Version() = default;
// Construct from a version string like "3.9.0-dev.14" or "3.9.0".
// Anything past a '-' or '+' is kept as the prerelease string and not interpreted.
explicit EMSESP_Version(const std::string & s) {
parse(s.c_str());
}
explicit EMSESP_Version(const char * s) {
parse(s ? s : "");
}
int major() const {
return major_;
}
int minor() const {
return minor_;
}
int patch() const {
return patch_;
}
const std::string & prerelease() const {
return prerelease_;
}
// Numeric-only comparison (major.minor.patch). Prerelease tags are ignored on purpose.
friend bool operator<(const EMSESP_Version & a, const EMSESP_Version & b) {
if (a.major_ != b.major_)
return a.major_ < b.major_;
if (a.minor_ != b.minor_)
return a.minor_ < b.minor_;
if (a.patch_ != b.patch_)
return a.patch_ < b.patch_;
return a.prerelease_ < b.prerelease_;
}
friend bool operator>(const EMSESP_Version & a, const EMSESP_Version & b) {
return b < a;
}
friend bool operator==(const EMSESP_Version & a, const EMSESP_Version & b) {
return a.major_ == b.major_ && a.minor_ == b.minor_ && a.patch_ == b.patch_ && a.prerelease_ == b.prerelease_;
}
friend bool operator!=(const EMSESP_Version & a, const EMSESP_Version & b) {
return !(a == b);
}
friend bool operator>=(const EMSESP_Version & a, const EMSESP_Version & b) {
return !(a < b);
}
friend bool operator<=(const EMSESP_Version & a, const EMSESP_Version & b) {
return !(b < a);
}
private:
int major_ = 0;
int minor_ = 0;
int patch_ = 0;
std::string prerelease_;
void parse(const char * s) {
major_ = minor_ = patch_ = 0;
prerelease_.clear();
if (s == nullptr || *s == '\0') {
return;
}
// parse numeric major.minor.patch; accept partial ("3", "3.9", "3.9.0")
sscanf(s, "%d.%d.%d", &major_, &minor_, &patch_);
// capture prerelease tag after '-' if present (stop at '+' which is build metadata)
const char * dash = strchr(s, '-');
if (dash != nullptr) {
const char * plus = strchr(dash, '+');
if (plus != nullptr) {
prerelease_.assign(dash + 1, plus - dash - 1);
} else {
prerelease_.assign(dash + 1);
}
}
}
};
} // namespace version
#endif

View File

@@ -1817,10 +1817,10 @@ void EMSESP::start() {
analogsensor_.start(factory_settings); // Analog external sensors analogsensor_.start(factory_settings); // Analog external sensors
// start web services // start web services
LOG_INFO("Starting Web Server");
webLogService.start(); // apply settings to weblog service webLogService.start(); // apply settings to weblog service
webModulesService.begin(); // setup the external library modules webModulesService.begin(); // setup the external library modules
webServer.begin(); // start the web server webServer.begin(); // start the web server
LOG_INFO("Starting Web Server");
} }
void EMSESP::start_serial_console() { void EMSESP::start_serial_console() {
@@ -1874,6 +1874,7 @@ void EMSESP::loop() {
} }
// loop through the services // loop through the services
webStatusService.loop(); // periodic refresh of cached versions.json
rxservice_.loop(); // process any incoming Rx telegrams rxservice_.loop(); // process any incoming Rx telegrams
shower_.loop(); // check for shower on/off shower_.loop(); // check for shower on/off
temperaturesensor_.loop(); // read sensor temperatures temperaturesensor_.loop(); // read sensor temperatures

View File

@@ -0,0 +1,100 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2026 emsesp.org
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "firmwareVersion.h"
#include <cstdio>
#include <cstring>
namespace emsesp {
FirmwareVersion::FirmwareVersion(const std::string & s) {
parse(s.c_str());
}
FirmwareVersion::FirmwareVersion(const char * s) {
parse(s ? s : "");
}
int FirmwareVersion::major() const {
return major_;
}
int FirmwareVersion::minor() const {
return minor_;
}
int FirmwareVersion::patch() const {
return patch_;
}
const std::string & FirmwareVersion::prerelease() const {
return prerelease_;
}
bool operator<(const FirmwareVersion & a, const FirmwareVersion & b) {
if (a.major_ != b.major_)
return a.major_ < b.major_;
if (a.minor_ != b.minor_)
return a.minor_ < b.minor_;
if (a.patch_ != b.patch_)
return a.patch_ < b.patch_;
return a.prerelease_ < b.prerelease_;
}
bool operator>(const FirmwareVersion & a, const FirmwareVersion & b) {
return b < a;
}
bool operator==(const FirmwareVersion & a, const FirmwareVersion & b) {
return a.major_ == b.major_ && a.minor_ == b.minor_ && a.patch_ == b.patch_ && a.prerelease_ == b.prerelease_;
}
bool operator!=(const FirmwareVersion & a, const FirmwareVersion & b) {
return !(a == b);
}
bool operator>=(const FirmwareVersion & a, const FirmwareVersion & b) {
return !(a < b);
}
bool operator<=(const FirmwareVersion & a, const FirmwareVersion & b) {
return !(b < a);
}
void FirmwareVersion::parse(const char * s) {
major_ = minor_ = patch_ = 0;
prerelease_.clear();
if (s == nullptr || *s == '\0') {
return;
}
// parse numeric major.minor.patch; accept partial ("3", "3.9", "3.9.0")
sscanf(s, "%d.%d.%d", &major_, &minor_, &patch_);
// capture prerelease tag after '-' if present (stop at '+' which is build metadata)
const char * dash = strchr(s, '-');
if (dash != nullptr) {
const char * plus = strchr(dash, '+');
if (plus != nullptr) {
prerelease_.assign(dash + 1, plus - dash - 1);
} else {
prerelease_.assign(dash + 1);
}
}
}
} // namespace emsesp

View File

@@ -0,0 +1,59 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2026 emsesp.org
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef firmwareVersion_H
#define firmwareVersion_H
#include <string>
namespace emsesp {
class FirmwareVersion {
public:
FirmwareVersion() = default;
// Construct from a version string like "3.9.0-dev.14" or "3.9.0".
// Anything past a '-' or '+' is kept as the prerelease string and not interpreted.
explicit FirmwareVersion(const std::string & s);
explicit FirmwareVersion(const char * s);
int major() const;
int minor() const;
int patch() const;
const std::string & prerelease() const;
// Numeric-only comparison (major.minor.patch). Prerelease tags are ignored on purpose.
friend bool operator<(const FirmwareVersion & a, const FirmwareVersion & b);
friend bool operator>(const FirmwareVersion & a, const FirmwareVersion & b);
friend bool operator==(const FirmwareVersion & a, const FirmwareVersion & b);
friend bool operator!=(const FirmwareVersion & a, const FirmwareVersion & b);
friend bool operator>=(const FirmwareVersion & a, const FirmwareVersion & b);
friend bool operator<=(const FirmwareVersion & a, const FirmwareVersion & b);
private:
int major_ = 0;
int minor_ = 0;
int patch_ = 0;
std::string prerelease_;
void parse(const char * s);
};
} // namespace emsesp
#endif

View File

@@ -32,7 +32,7 @@
#include <HTTPClient.h> #include <HTTPClient.h>
#include <map> #include <map>
#include "EMSESP_Version.h" #include "firmwareVersion.h"
#if defined(EMSESP_TEST) #if defined(EMSESP_TEST)
#include "../test/test.h" #include "../test/test.h"
@@ -991,7 +991,6 @@ void System::heartbeat_json(JsonObject output) {
#if CONFIG_IDF_TARGET_ESP32S3 || CONFIG_IDF_TARGET_ESP32C3 || CONFIG_IDF_TARGET_ESP32S2 #if CONFIG_IDF_TARGET_ESP32S3 || CONFIG_IDF_TARGET_ESP32C3 || CONFIG_IDF_TARGET_ESP32S2
output["temperature"] = (int)temperature_; output["temperature"] = (int)temperature_;
#endif #endif
#endif
#ifndef EMSESP_STANDALONE #ifndef EMSESP_STANDALONE
if (!EMSESP::network_.ethernet_connected()) { if (!EMSESP::network_.ethernet_connected()) {
@@ -1001,6 +1000,11 @@ void System::heartbeat_json(JsonObject output) {
output["wifireconnects"] = EMSESP::network_.getWifiReconnects(); output["wifireconnects"] = EMSESP::network_.getWifiReconnects();
} }
#endif #endif
// see if there is a newer version available
if (EMSESP::webStatusService.versions_cache_valid()) {
output["upgradeable"] = EMSESP::webStatusService.current_upgradeable();
}
} }
// send periodic MQTT message with system information // send periodic MQTT message with system information
@@ -1570,8 +1574,8 @@ bool System::check_upgrade() {
settingsVersion = "3.5.0"; // this was the last stable version without version info settingsVersion = "3.5.0"; // this was the last stable version without version info
} }
version::EMSESP_Version settings_version(settingsVersion); FirmwareVersion settings_version(settingsVersion);
version::EMSESP_Version this_version(EMSESP_APP_VERSION); FirmwareVersion this_version(EMSESP_APP_VERSION);
std::string settings_version_type = settings_version.prerelease().empty() ? "" : ("-" + settings_version.prerelease()); std::string settings_version_type = settings_version.prerelease().empty() ? "" : ("-" + settings_version.prerelease());
std::string this_version_type = this_version.prerelease().empty() ? "" : ("-" + this_version.prerelease()); std::string this_version_type = this_version.prerelease().empty() ? "" : ("-" + this_version.prerelease());

View File

@@ -173,7 +173,7 @@ Thermostat::Thermostat(uint8_t device_type, uint8_t device_id, uint8_t product_i
for (uint8_t i = 0; i < monitor_size; i++) { for (uint8_t i = 0; i < monitor_size; i++) {
register_telegram_type(monitor_typeids[i], "RC300Monitor", false, MAKE_PF_CB(process_RC300Monitor), 33); register_telegram_type(monitor_typeids[i], "RC300Monitor", false, MAKE_PF_CB(process_RC300Monitor), 33);
register_telegram_type(set_typeids[i], "RC300Set", false, MAKE_PF_CB(process_RC300Set), 29); register_telegram_type(set_typeids[i], "RC300Set", false, MAKE_PF_CB(process_RC300Set), 29);
register_telegram_type(summer_typeids[i], "RC300Summer", false, MAKE_PF_CB(process_RC300Summer), 13); register_telegram_type(summer_typeids[i], "RC300Summer", false, MAKE_PF_CB(process_RC300Summer), 14);
register_telegram_type(curve_typeids[i], "RC300Curves", false, MAKE_PF_CB(process_RC300Curve), 9); register_telegram_type(curve_typeids[i], "RC300Curves", false, MAKE_PF_CB(process_RC300Curve), 9);
register_telegram_type(summer2_typeids[i], "RC300Summer2", false, MAKE_PF_CB(process_RC300Summer2), 8); register_telegram_type(summer2_typeids[i], "RC300Summer2", false, MAKE_PF_CB(process_RC300Summer2), 8);
} }

View File

@@ -1329,16 +1329,6 @@ void Test::run_test(uuid::console::Shell & shell, const std::string & cmd, const
// request.url("/rest/action"); // request.url("/rest/action");
// EMSESP::webStatusService.action(&request, doc.as<JsonVariant>()); // EMSESP::webStatusService.action(&request, doc.as<JsonVariant>());
// test version checks
// use same data as in restServer.ts
// log shows first if you can upgrade to dev, and then if you can upgrade to stable
// request.url("/rest/action");
// std::string LATEST_STABLE_VERSION = "3.8.0";
// std::string LATEST_DEV_VERSION = "3.8.1-dev.3";
// std::string param = LATEST_DEV_VERSION + "," + LATEST_STABLE_VERSION;
// std::string action = "{\"action\":\"checkUpgrade\", \"param\":\"" + param + "\"}";
// deserializeJson(doc, action);
// // case 0: on latest stable, can upgrade to dev only. So true, false // // case 0: on latest stable, can upgrade to dev only. So true, false
// EMSESP::webStatusService.set_current_version(LATEST_STABLE_VERSION); // EMSESP::webStatusService.set_current_version(LATEST_STABLE_VERSION);
// EMSESP::webStatusService.action(&request, doc.as<JsonVariant>()); // EMSESP::webStatusService.action(&request, doc.as<JsonVariant>());

View File

@@ -20,6 +20,7 @@
#ifndef EMSESP_STANDALONE #ifndef EMSESP_STANDALONE
#include <esp_ota_ops.h> #include <esp_ota_ops.h>
#include <HTTPClient.h>
#endif #endif
namespace emsesp { namespace emsesp {
@@ -205,11 +206,11 @@ void WebStatusService::action(AsyncWebServerRequest * request, JsonVariant json)
bool is_admin = AuthenticationPredicates::IS_ADMIN(authentication); bool is_admin = AuthenticationPredicates::IS_ADMIN(authentication);
// call action command // call action command
bool ok = false; bool ok = true;
std::string action = json["action"]; std::string action = json["action"];
if (action == "checkUpgrade") { if (action == "getVersions") {
ok = checkUpgrade(root, param); // param could be empty, if so only send back version getVersions(root);
} else if (action == "setPartition") { } else if (action == "setPartition") {
ok = EMSESP::system_.set_partition(param.c_str()); ok = EMSESP::system_.set_partition(param.c_str());
} else if (action == "export") { } else if (action == "export") {
@@ -224,10 +225,8 @@ void WebStatusService::action(AsyncWebServerRequest * request, JsonVariant json)
ok = setSystemStatus(param.c_str()); ok = setSystemStatus(param.c_str());
} else if (action == "resetMQTT" && is_admin) { } else if (action == "resetMQTT" && is_admin) {
EMSESP::mqtt_.reset_mqtt(); EMSESP::mqtt_.reset_mqtt();
ok = true;
} else if (action == "upgradeImportantMessages") { } else if (action == "upgradeImportantMessages") {
root["upgradeImportantMessageType"] = upgradeImportantMessages(param); root["upgradeImportantMessageType"] = upgradeImportantMessages(param);
ok = true;
} }
#if defined(EMSESP_STANDALONE) && !defined(EMSESP_UNITY) #if defined(EMSESP_STANDALONE) && !defined(EMSESP_UNITY)
@@ -262,7 +261,7 @@ uint8_t WebStatusService::upgradeImportantMessages(std::string & version) {
// it's a filename with a .bin or .md extension, try and extract the version from it // it's a filename with a .bin or .md extension, try and extract the version from it
// e.g. EMS-ESP-3_8_2-dev_13-ESP32-16MB+.bin -> major=3 minor=8 patch=2 // e.g. EMS-ESP-3_8_2-dev_13-ESP32-16MB+.bin -> major=3 minor=8 patch=2
version::EMSESP_Version latest_version; FirmwareVersion latest_version;
if ((version.find(".bin") != std::string::npos) || (version.find(".md") != std::string::npos)) { if ((version.find(".bin") != std::string::npos) || (version.find(".md") != std::string::npos)) {
std::string filename = version; std::string filename = version;
auto pos = filename.find("EMS-ESP-"); auto pos = filename.find("EMS-ESP-");
@@ -283,18 +282,18 @@ uint8_t WebStatusService::upgradeImportantMessages(std::string & version) {
std::string major_version = filename.substr(pos, underscore1 - pos); std::string major_version = filename.substr(pos, underscore1 - pos);
std::string minor_version = filename.substr(underscore1 + 1, underscore2 - underscore1 - 1); std::string minor_version = filename.substr(underscore1 + 1, underscore2 - underscore1 - 1);
std::string patch_version = filename.substr(underscore2 + 1, dash - underscore2 - 1); std::string patch_version = filename.substr(underscore2 + 1, dash - underscore2 - 1);
latest_version = version::EMSESP_Version(major_version + "." + minor_version + "." + patch_version); latest_version = FirmwareVersion(major_version + "." + minor_version + "." + patch_version);
} else { } else {
// if it's .json file exit // if it's .json file exit
if (version.find(".json") != std::string::npos) { if (version.find(".json") != std::string::npos) {
return 0; return 0;
} else { } else {
// treat it like a version string like "3.9.0" // treat it like a version string like "3.9.0"
latest_version = version::EMSESP_Version(version); latest_version = FirmwareVersion(version);
} }
} }
version::EMSESP_Version current_version(current_version_s); // get current version FirmwareVersion current_version(current_version_s); // get current version
if ((current_version.major() <= 3 && current_version.minor() <= 8) && (latest_version.major() == 3 && latest_version.minor() == 9)) { if ((current_version.major() <= 3 && current_version.minor() <= 8) && (latest_version.major() == 3 && latest_version.minor() == 9)) {
return 1; // if moving from below 3.8.x to 3.9.x return 1 return 1; // if moving from below 3.8.x to 3.9.x return 1
@@ -311,46 +310,164 @@ uint8_t WebStatusService::upgradeImportantMessages(std::string & version) {
return 0; // if it's not a valid version upgrade return 0 return 0; // if it's not a valid version upgrade return 0
} }
// action = checkUpgrade // action = getVersions
// versions holds the latest development version and stable version in one string, comma separated // returns the device's current version for dev and stable
bool WebStatusService::checkUpgrade(JsonObject root, std::string & version) { // The remote fetch runs from the main loop task via WebStatusService::loop() so that we never block the AsyncTCP callback
if (!version.empty()) { void WebStatusService::getVersions(JsonObject root) {
version::EMSESP_Version current_version(current_version_s); FirmwareVersion current_version(current_version_s);
version::EMSESP_Version latest_dev_version(version.substr(0, version.find(','))); bool is_dev = current_version.prerelease().find("dev") != std::string::npos;
version::EMSESP_Version latest_stable_version(version.substr(version.find(',') + 1));
bool dev_upgradeable = latest_dev_version > current_version; JsonObject current = root["current"].to<JsonObject>();
bool stable_upgradeable = latest_stable_version > current_version; current["version"] = current_version_s;
current["type"] = is_dev ? "dev" : "stable";
current["date"] = "";
current["upgradeable"] = current_upgradeable(); // false if cache not valid yet
#if defined(EMSESP_DEBUG) #ifndef EMSESP_STANDALONE
// look for dev in the name to determine if we're using a dev release // pull the install_date for the running partition (if known)
bool using_dev_version = !current_version.prerelease().find("dev"); const esp_partition_t * running = esp_ota_get_running_partition();
EMSESP::logger() if (running != nullptr) {
.debug("Checking version upgrade. This version=%d.%d.%d-%s (%s),latest dev=%d.%d.%d-%s (%s upgradeable),latest stable=%d.%d.%d-%s (%s upgradeable)", const auto & info = EMSESP::system_.partition_info_;
current_version.major(), auto it = info.find(running->label);
current_version.minor(), if (it != info.end() && it->second.install_date > 0) {
current_version.patch(), char time_string[25];
current_version.prerelease().c_str(), time_t d = it->second.install_date;
using_dev_version ? "Dev" : "Stable", strftime(time_string, sizeof(time_string), "%FT%T", localtime(&d));
latest_dev_version.major(), current["date"] = time_string;
latest_dev_version.minor(), }
latest_dev_version.patch(),
latest_dev_version.prerelease().c_str(),
dev_upgradeable ? "is" : "is not",
latest_stable_version.major(),
latest_stable_version.minor(),
latest_stable_version.patch(),
latest_stable_version.prerelease().c_str(),
stable_upgradeable ? "is" : "is not");
#endif
root["dev_upgradeable"] = dev_upgradeable;
root["stable_upgradeable"] = stable_upgradeable;
} }
root["emsesp_version"] = current_version_s; // always send back current version if (!versions_cache_valid_) {
// no successful fetch yet (no network, fetch pending, or parse error)
return;
}
// copies a cached entry into root[key]
auto add_section = [&](const char * key, const VersionInfo & info) {
if (info.version.empty()) {
return;
}
JsonObject out = root[key].to<JsonObject>();
out["version"] = info.version;
out["date"] = info.date;
out["upgradeable"] = info.upgradeable;
};
add_section("stable", versions_stable_);
add_section("dev", versions_dev_);
#else
// standalone/test build: provide deterministic dummy data
JsonObject stable_out = root["stable"].to<JsonObject>();
stable_out["version"] = "3.8.2";
stable_out["date"] = "2026-04-25";
stable_out["upgradeable"] = FirmwareVersion("3.8.2") > current_version;
JsonObject dev_out = root["dev"].to<JsonObject>();
dev_out["version"] = "3.9.0-dev.1";
dev_out["date"] = "2026-04-25";
dev_out["upgradeable"] = FirmwareVersion("3.9.0-dev.1") > current_version;
#endif
}
// periodic refresh (1 hour) of the cached versions.json
// runs on the main loop task, which has a much bigger stack than AsyncTCP needed for https
void WebStatusService::loop() {
#ifndef EMSESP_STANDALONE
// need a network
if (!EMSESP::system_.ethernet_connected() && (WiFi.status() != WL_CONNECTED)) {
return;
}
// 0 = idle, nothing scheduled
if (versions_next_fetch_ms_ == 0) {
return;
}
// not time yet (signed difference handles uint32 wrap)
if ((int32_t)(uuid::get_uptime() - versions_next_fetch_ms_) < 0) {
return;
}
bool ok = refresh_versions_cache();
uint32_t next = uuid::get_uptime() + (ok ? VERSIONS_REFRESH_INTERVAL_MS : VERSIONS_RETRY_INTERVAL_MS);
if (next == 0) {
next = 1;
}
versions_next_fetch_ms_ = next;
#endif
}
// runs on the main loop task — never call this from an AsyncWebServer handler
bool WebStatusService::refresh_versions_cache() {
#ifdef EMSESP_STANDALONE
return false;
#else
HTTPClient http;
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
http.setTimeout(5000);
http.useHTTP10(true);
if (!http.begin(EMSESP_VERSIONS_URL)) {
#if defined(EMSESP_DEBUG)
EMSESP::logger().debug("versions.json: failed to start HTTPS request");
#endif
return false;
}
int httpCode = http.GET();
if (httpCode != HTTP_CODE_OK) {
#if defined(EMSESP_DEBUG)
EMSESP::logger().debug("versions.json: HTTP %d", httpCode);
#endif
http.end();
return false;
}
JsonDocument doc;
DeserializationError err = deserializeJson(doc, http.getStream());
http.end();
if (err) {
#if defined(EMSESP_DEBUG)
EMSESP::logger().debug("versions.json: parse error");
#endif
return false;
}
FirmwareVersion current_version(current_version_s);
auto read_section = [&doc, &current_version](const char * key, VersionInfo & out) {
JsonObjectConst section = doc[key];
if (section.isNull()) {
out = {};
return;
}
out.version = section["version"] | "";
out.date = section["date"] | "";
out.upgradeable = !out.version.empty() && FirmwareVersion(out.version) > current_version;
};
read_section("stable", versions_stable_);
read_section("dev", versions_dev_);
versions_cache_valid_ = true;
#if defined(EMSESP_DEBUG)
EMSESP::logger().debug("versions.json: refreshed (stable=%s dev=%s), current=%s",
versions_stable_.version.c_str(),
versions_dev_.version.c_str(),
current_version_s.c_str());
#endif
return true; return true;
#endif
}
// returns if current dev/stable is upgradeable
bool WebStatusService::current_upgradeable() const {
if (!versions_cache_valid_) {
return false;
}
FirmwareVersion current_version(current_version_s);
bool is_dev = current_version.prerelease().find("dev") != std::string::npos;
return is_dev ? versions_dev_.upgradeable : versions_stable_.upgradeable;
} }
// action = allvalues // action = allvalues

View File

@@ -4,7 +4,9 @@
#define EMSESP_SYSTEM_STATUS_SERVICE_PATH "/rest/systemStatus" #define EMSESP_SYSTEM_STATUS_SERVICE_PATH "/rest/systemStatus"
#define EMSESP_ACTION_SERVICE_PATH "/rest/action" #define EMSESP_ACTION_SERVICE_PATH "/rest/action"
#include "../core/EMSESP_Version.h" #define EMSESP_VERSIONS_URL "http://emsesp.org/versions.json"
#include "../core/firmwareVersion.h"
#include "../emsesp_version.h" #include "../emsesp_version.h"
namespace emsesp { namespace emsesp {
@@ -19,6 +21,22 @@ class WebStatusService {
return current_version_s; return current_version_s;
} }
// called from EMSESP::loop() to refresh the cached versions.json from emsesp.org
// so that the web request handler never has to do blocking HTTPS on the small AsyncTCP stack
void loop();
// true once we've had at least one successful versions.json fetch
bool versions_cache_valid() const {
return versions_cache_valid_;
}
// refresh the versions.json cache
void schedule_versions_refresh() {
versions_next_fetch_ms_ = 1;
}
bool current_upgradeable() const; // true if a newer version is available
// make action function public so we can test in the debug and standalone mode // make action function public so we can test in the debug and standalone mode
#ifndef EMSESP_STANDALONE #ifndef EMSESP_STANDALONE
protected: protected:
@@ -30,7 +48,7 @@ class WebStatusService {
SecurityManager * _securityManager; SecurityManager * _securityManager;
// actions // actions
bool checkUpgrade(JsonObject root, std::string & latest_version); void getVersions(JsonObject root);
bool exportData(JsonObject root, std::string & type); bool exportData(JsonObject root, std::string & type);
bool getCustomSupport(JsonObject root); bool getCustomSupport(JsonObject root);
bool uploadURL(const char * url); bool uploadURL(const char * url);
@@ -39,6 +57,22 @@ class WebStatusService {
uint8_t upgradeImportantMessages(std::string & version); uint8_t upgradeImportantMessages(std::string & version);
std::string current_version_s = EMSESP_APP_VERSION; std::string current_version_s = EMSESP_APP_VERSION;
// cached emsesp.org/versions.json. Refreshed from the main loop task, which has more stack.
struct VersionInfo {
std::string version;
std::string date;
bool upgradeable = false;
};
VersionInfo versions_stable_;
VersionInfo versions_dev_;
bool versions_cache_valid_ = false; // true once we've had at least one successful fetch
uint32_t versions_next_fetch_ms_ = 0; // uuid::get_uptime() of the next attempt; 0 = idle
bool refresh_versions_cache(); // does the actual HTTPS fetch + parse, returns true on success
static constexpr uint32_t VERSIONS_REFRESH_INTERVAL_MS = 60UL * 60UL * 1000UL; // 1 hour on success
static constexpr uint32_t VERSIONS_RETRY_INTERVAL_MS = 5UL * 60UL * 1000UL; // 5 min after failure
}; };
} // namespace emsesp } // namespace emsesp

View File

@@ -3,9 +3,8 @@
# Open this file in VSC, modify the token, go to the API call and click on 'Send Request' (or Ctrl+Alt+R) # Open this file in VSC, modify the token, go to the API call and click on 'Send Request' (or Ctrl+Alt+R)
# The response will be shown in the right panel # The response will be shown in the right panel
# @host = http://ems-esp.local @host = http://ems-esp.local
@host = http://192.168.1.223 @host_dev = http://ems-espT.local
@host_dev = http://192.168.1.65
@host_standalone = http://localhost:3080 @host_standalone = http://localhost:3080
@host_standalone2 = http://localhost:3082 @host_standalone2 = http://localhost:3082

View File

@@ -4,7 +4,8 @@
# Command line test for the API # Command line test for the API
# #
emsesp_url="http://192.168.1.223" # emsesp_url="http://ems-esp.local"
emsesp_url="http://ems-espT.local"
# get the token from the Security page. This is the token for the admin user, unless changed it'll always be the same # get the token from the Security page. This is the token for the admin user, unless changed it'll always be the same
emsesp_token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiYWRtaW4iOnRydWV9.2bHpWya2C7Q12WjNUBD6_7N3RCD7CMl-EGhyQVzFdDg" emsesp_token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiYWRtaW4iOnRydWV9.2bHpWya2C7Q12WjNUBD6_7N3RCD7CMl-EGhyQVzFdDg"
@@ -40,13 +41,22 @@ curl -X POST \
echo "\n" echo "\n"
# Get all versions
curl -X POST \
-H "Authorization: Bearer ${emsesp_token}" \
-H "Content-Type: application/json" \
-d '{"action":"getVersions"}' \
${emsesp_url}/rest/action
echo "\n"
# This example is how to call a service in Home Assistant via the API # This example is how to call a service in Home Assistant via the API
# Which can be added to an EMS-EPS schedule # Which can be added to an EMS-EPS schedule
#
ha_url="http://192.168.1.86:8123" # ha_url="http://192.168.1.86:8123"
ha_token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIwMzMyZjU1MjhlZmM0NGIyOTgyMjIxNThiODU1NDkyNSIsImlhdCI6MTcyMTMwNDg2NSwiZXhwIjoyMDM2NjY0ODY1fQ.Q-Y7E_i7clH3ff4Ma-OMmhZfbN7aMi_CahKwmoar" # ha_token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIwMzMyZjU1MjhlZmM0NGIyOTgyMjIxNThiODU1NDkyNSIsImlhdCI6MTcyMTMwNDg2NSwiZXhwIjoyMDM2NjY0ODY1fQ.Q-Y7E_i7clH3ff4Ma-OMmhZfbN7aMi_CahKwmoar"
#
curl -X POST \ # curl -X POST \
${ha_url}/api/services/script/test_notify \ # ${ha_url}/api/services/script/test_notify \
-H "Authorization: Bearer ${ha_token}" \ # -H "Authorization: Bearer ${ha_token}" \
-H "Content-Type: application/json" # -H "Content-Type: application/json"