Merge branch 'dev'

This commit is contained in:
proddy
2025-12-31 21:26:15 +01:00
parent eaa277fef0
commit 28135c225b
385 changed files with 40221 additions and 38187 deletions

View File

@@ -1,4 +0,0 @@
/.yarn/** linguist-vendored
/.yarn/releases/* binary
/.yarn/plugins/**/* binary
/.pnp.* binary linguist-generated

View File

@@ -4,5 +4,4 @@ dist/
src/i18n/*
.prettierrc
.yarn/
.typesafe-i18n.json
.typesafe-i18n.json

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +0,0 @@
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.7.0.cjs

View File

@@ -10,8 +10,7 @@ export default tseslint.config(
{
languageOptions: {
parserOptions: {
project: true,
tsconfigRootDir: import.meta.dirname
project: true
}
}
},

View File

@@ -1,6 +1,6 @@
{
"name": "EMS-ESP",
"version": "3.7.2",
"version": "3.8.0",
"description": "EMS-ESP WebUI",
"homepage": "https://emsesp.org",
"author": "proddy, emsesp.org",
@@ -8,59 +8,64 @@
"private": true,
"type": "module",
"scripts": {
"preinstall": "npx only-allow pnpm",
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"build-hosted": "typesafe-i18n --no-watch && vite build --mode hosted",
"preview-standalone": "typesafe-i18n --no-watch && vite build && concurrently -c \"auto\" \"yarn:mock-rest\" \"vite preview\"",
"mock-rest": "bun --watch ../mock-api/rest_server.ts",
"standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"yarn:mock-rest\" \"vite\"",
"build-hosted": "typesafe-i18n && vite build --mode hosted",
"mock-rest": "bun --watch ../mock-api/restServer.ts",
"preview-standalone": "typesafe-i18n --no-watch && vite build && concurrently -c \"auto\" \"pnpm:mock-rest\" \"vite preview\"",
"standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite dev\"",
"typesafe-i18n": "typesafe-i18n --no-watch",
"webUI": "node progmem-generator.js",
"build_webUI": "typesafe-i18n --no-watch && vite build && node progmem-generator.js",
"format": "prettier -l -w '**/*.{ts,tsx,js,css,json,md}'",
"lint": "eslint . --fix"
"lint": "eslint . --fix",
"standalone-devcontainer": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite --host\""
},
"dependencies": {
"@alova/adapter-xhr": "2.1.1",
"@alova/adapter-xhr": "2.3.1",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^6.4.8",
"@mui/material": "^6.4.8",
"@table-library/react-table-library": "4.1.12",
"alova": "3.2.10",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.6",
"@mui/material": "^7.3.6",
"@preact/compat": "^18.3.1",
"@table-library/react-table-library": "4.1.15",
"alova": "3.4.1",
"async-validator": "^4.2.5",
"etag": "^1.8.1",
"formidable": "^3.5.4",
"jwt-decode": "^4.0.0",
"mime-types": "^2.1.35",
"preact": "^10.26.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"magic-string": "^0.30.21",
"mime-types": "^3.0.2",
"preact": "^10.28.1",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-icons": "^5.5.0",
"react-router": "^7.4.0",
"react-router": "^7.11.0",
"react-toastify": "^11.0.5",
"typesafe-i18n": "^5.26.2",
"typescript": "^5.8.2"
"typescript": "^5.9.3"
},
"devDependencies": {
"@babel/core": "^7.26.10",
"@eslint/js": "^9.23.0",
"@babel/core": "^7.28.5",
"@eslint/js": "^9.39.2",
"@preact/compat": "^18.3.1",
"@preact/preset-vite": "^2.10.1",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/formidable": "^3",
"@types/node": "^22.13.11",
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
"concurrently": "^9.1.2",
"eslint": "^9.23.0",
"eslint-config-prettier": "^10.1.1",
"formidable": "^3.5.2",
"prettier": "^3.5.3",
"rollup-plugin-visualizer": "^5.14.0",
"terser": "^5.39.0",
"typescript-eslint": "8.27.0",
"vite": "^6.2.2",
"@preact/preset-vite": "^2.10.2",
"@trivago/prettier-plugin-sort-imports": "^6.0.0",
"@types/node": "^25.0.3",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"axe-core": "^4.11.0",
"concurrently": "^9.2.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.7.4",
"rollup-plugin-visualizer": "^6.0.5",
"terser": "^5.44.1",
"typescript-eslint": "^8.51.0",
"vite": "^7.3.0",
"vite-plugin-imagemin": "^0.6.1",
"vite-tsconfig-paths": "^5.1.4"
"vite-tsconfig-paths": "^6.0.3"
},
"packageManager": "yarn@4.7.0"
"packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a"
}

View File

@@ -0,0 +1,8 @@
onlyBuiltDependencies:
- cwebp-bin
- esbuild
- gifsicle
- jpegtran-bin
- mozjpeg
- optipng-bin
- pngquant-bin

View File

@@ -1,4 +1,4 @@
import crypto from 'crypto';
import etag from 'etag';
import {
createWriteStream,
existsSync,
@@ -15,67 +15,80 @@ const INDENT = ' ';
const outputPath = '../src/ESP32React/WWWData.h';
const sourcePath = './dist';
const bytesPerLine = 20;
var totalSize = 0;
let totalSize = 0;
let bundleStats = {
js: { count: 0, uncompressed: 0, compressed: 0 },
css: { count: 0, uncompressed: 0, compressed: 0 },
html: { count: 0, uncompressed: 0, compressed: 0 },
svg: { count: 0, uncompressed: 0, compressed: 0 },
other: { count: 0, uncompressed: 0, compressed: 0 }
};
const generateWWWClass = () =>
`typedef std::function<void(const char * uri, const String & contentType, const uint8_t * content, size_t len, const String & hash)> RouteRegistrationHandler;
// Total size is ${totalSize} bytes
const generateWWWClass =
() => `typedef std::function<void(const char * uri, const String & contentType, const uint8_t * content, size_t len, const String & hash)> RouteRegistrationHandler;
// Bundle Statistics:
// - Total compressed size: ${(totalSize / 1000).toFixed(1)} KB
// - Total uncompressed size: ${(Object.values(bundleStats).reduce((sum, stat) => sum + stat.uncompressed, 0) / 1000).toFixed(1)} KB
// - Compression ratio: ${(((Object.values(bundleStats).reduce((sum, stat) => sum + stat.uncompressed, 0) - totalSize) / Object.values(bundleStats).reduce((sum, stat) => sum + stat.uncompressed, 0)) * 100).toFixed(1)}%
// - Generated on: ${new Date().toISOString()}
class WWWData {
${indent}public:
${indent.repeat(2)}static void registerRoutes(RouteRegistrationHandler handler) {
${fileInfo.map((file) => `${indent.repeat(3)}handler("${file.uri}", "${file.mimeType}", ${file.variable}, ${file.size}, "${file.hash}");`).join('\n')}
${indent.repeat(2)}}
${INDENT}public:
${INDENT.repeat(2)}static void registerRoutes(RouteRegistrationHandler handler) {
${fileInfo.map((f) => `${INDENT.repeat(3)}handler("${f.uri}", "${f.mimeType}", ${f.variable}, ${f.size}, ${f.hash});`).join('\n')}
${INDENT.repeat(2)}}
};
`;
function getFilesSync(dir, files = []) {
const getFilesSync = (dir, files = []) => {
readdirSync(dir, { withFileTypes: true }).forEach((entry) => {
const entryPath = resolve(dir, entry.name);
if (entry.isDirectory()) {
getFilesSync(entryPath, files);
} else {
files.push(entryPath);
}
entry.isDirectory() ? getFilesSync(entryPath, files) : files.push(entryPath);
});
return files;
}
};
function cleanAndOpen(path) {
if (existsSync(path)) {
unlinkSync(path);
}
const cleanAndOpen = (path) => {
existsSync(path) && unlinkSync(path);
return createWriteStream(path, { flags: 'w+' });
}
};
const getFileType = (filePath) => {
const ext = filePath.split('.').pop().toLowerCase();
if (ext === 'js') return 'js';
if (ext === 'css') return 'css';
if (ext === 'html') return 'html';
if (ext === 'svg') return 'svg';
return 'other';
};
const writeFile = (relativeFilePath, buffer) => {
const variable = 'ESP_REACT_DATA_' + fileInfo.length;
const variable = `ESP_REACT_DATA_${fileInfo.length}`;
const mimeType = mime.lookup(relativeFilePath);
var size = 0;
writeStream.write('const uint8_t ' + variable + '[] = {');
// const zipBuffer = zlib.brotliCompressSync(buffer, { quality: 1 });
const zipBuffer = zlib.gzipSync(buffer, { level: 9 });
const fileType = getFileType(relativeFilePath);
let size = 0;
writeStream.write(`const uint8_t ${variable}[] = {`);
// create sha
const hashSum = crypto.createHash('sha256');
hashSum.update(zipBuffer);
const hash = hashSum.digest('hex');
const zipBuffer = zlib.gzipSync(buffer, { level: 9 });
// const hash = crypto.createHash('sha256').update(zipBuffer).digest('hex');
const hash = etag(zipBuffer); // use smaller md5 instead of sha256
zipBuffer.forEach((b) => {
if (!(size % bytesPerLine)) {
writeStream.write('\n');
writeStream.write(indent);
writeStream.write('\n' + INDENT);
}
writeStream.write('0x' + ('00' + b.toString(16).toUpperCase()).slice(-2) + ',');
writeStream.write('0x' + b.toString(16).toUpperCase().padStart(2, '0') + ',');
size++;
});
if (size % bytesPerLine) {
writeStream.write('\n');
}
size % bytesPerLine && writeStream.write('\n');
writeStream.write('};\n\n');
// Update bundle statistics
bundleStats[fileType].count++;
bundleStats[fileType].uncompressed += buffer.length;
bundleStats[fileType].compressed += zipBuffer.length;
fileInfo.push({
uri: '/' + relativeFilePath.replace(sep, '/'),
mimeType,
@@ -84,32 +97,52 @@ const writeFile = (relativeFilePath, buffer) => {
hash
});
// console.log(relativeFilePath + ' (size ' + size + ' bytes)');
totalSize += size;
};
// start
console.log('Generating ' + outputPath + ' from ' + sourcePath);
const includes = ARDUINO_INCLUDES;
const indent = INDENT;
console.log(`Generating ${outputPath} from ${sourcePath}`);
const fileInfo = [];
const writeStream = cleanAndOpen(resolve(outputPath));
// includes
writeStream.write(includes);
writeStream.write(ARDUINO_INCLUDES);
// process static files
const buildPath = resolve(sourcePath);
for (const filePath of getFilesSync(buildPath)) {
const readStream = readFileSync(filePath);
const relativeFilePath = relative(buildPath, filePath);
writeFile(relativeFilePath, readStream);
writeFile(relative(buildPath, filePath), readFileSync(filePath));
}
// add class
writeStream.write(generateWWWClass());
// end
writeStream.end();
console.log('Total size: ' + totalSize / 1000 + ' KB');
// Calculate and display bundle statistics
const totalUncompressed = Object.values(bundleStats).reduce(
(sum, stat) => sum + stat.uncompressed,
0
);
const totalCompressed = Object.values(bundleStats).reduce(
(sum, stat) => sum + stat.compressed,
0
);
const compressionRatio = (
((totalUncompressed - totalCompressed) / totalUncompressed) *
100
).toFixed(1);
console.log('\n📊 Bundle Size Analysis:');
console.log('='.repeat(50));
console.log(`Total compressed size: ${(totalSize / 1000).toFixed(1)} KB`);
console.log(`Total uncompressed size: ${(totalUncompressed / 1000).toFixed(1)} KB`);
console.log(`Compression ratio: ${compressionRatio}%`);
console.log('\n📁 File Type Breakdown:');
Object.entries(bundleStats).forEach(([type, stats]) => {
if (stats.count > 0) {
const ratio = (
((stats.uncompressed - stats.compressed) / stats.uncompressed) *
100
).toFixed(1);
console.log(
`${type.toUpperCase().padEnd(4)}: ${stats.count} files, ${(stats.uncompressed / 1000).toFixed(1)} KB → ${(stats.compressed / 1000).toFixed(1)} KB (${ratio}% compression)`
);
}
});
console.log('='.repeat(50));

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { memo, useCallback, useEffect, useState } from 'react';
import { ToastContainer, Zoom } from 'react-toastify';
import AppRouting from 'AppRouting';
@@ -8,7 +8,7 @@ import type { Locales } from 'i18n/i18n-types';
import { loadLocaleAsync } from 'i18n/i18n-util.async';
import { detectLocale, navigatorDetector } from 'typesafe-i18n/detectors';
const availableLocales = [
const AVAILABLE_LOCALES = [
'de',
'en',
'it',
@@ -20,47 +20,56 @@ const availableLocales = [
'sv',
'tr',
'cz'
];
] as Locales[];
const App = () => {
// Static toast configuration - no need to recreate on every render
const TOAST_CONTAINER_PROPS = {
position: 'bottom-left' as const,
autoClose: 3000,
hideProgressBar: false,
newestOnTop: false,
closeOnClick: true,
rtl: false,
pauseOnFocusLoss: true,
draggable: false,
pauseOnHover: false,
transition: Zoom,
closeButton: false,
theme: 'dark' as const,
toastStyle: {
border: '1px solid #177ac9',
width: 'fit-content'
}
};
const App = memo(() => {
const [wasLoaded, setWasLoaded] = useState(false);
const [locale, setLocale] = useState<Locales>('en');
useEffect(() => {
// determine locale, take from session if set other default to browser language
const browserLocale = detectLocale('en', availableLocales, navigatorDetector);
// Memoize locale initialization to prevent unnecessary re-runs
const initializeLocale = useCallback(async () => {
const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector);
const newLocale = (localStorage.getItem('lang') || browserLocale) as Locales;
localStorage.setItem('lang', newLocale);
setLocale(newLocale);
void loadLocaleAsync(newLocale).then(() => setWasLoaded(true));
await loadLocaleAsync(newLocale);
setWasLoaded(true);
}, []);
useEffect(() => {
void initializeLocale();
}, [initializeLocale]);
if (!wasLoaded) return null;
return (
<TypesafeI18n locale={locale}>
<CustomTheme>
<AppRouting />
<ToastContainer
position="bottom-left"
autoClose={3000}
hideProgressBar={false}
newestOnTop={false}
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable={false}
pauseOnHover={false}
transition={Zoom}
closeButton={false}
theme="dark"
toastStyle={{
border: '1px solid #177ac9'
}}
/>
<ToastContainer {...TOAST_CONTAINER_PROPS} />
</CustomTheme>
</TypesafeI18n>
);
};
});
export default App;

View File

@@ -1,60 +1,80 @@
import { useContext, useEffect } from 'react';
import { type FC, Suspense, lazy, memo, useContext, useEffect, useRef } from 'react';
import { Navigate, Route, Routes } from 'react-router';
import { toast } from 'react-toastify';
import AuthenticatedRouting from 'AuthenticatedRouting';
import SignIn from 'SignIn';
import { RequireAuthenticated, RequireUnauthenticated } from 'components';
import {
LoadingSpinner,
RequireAuthenticated,
RequireUnauthenticated
} from 'components';
import { Authentication, AuthenticationContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
// Lazy load route components for better code splitting
const SignIn = lazy(() => import('SignIn'));
const AuthenticatedRouting = lazy(() => import('AuthenticatedRouting'));
interface SecurityRedirectProps {
message: string;
signOut?: boolean;
readonly message: string;
readonly signOut?: boolean;
}
const RootRedirect = ({ message, signOut }: SecurityRedirectProps) => {
const authenticationContext = useContext(AuthenticationContext);
useEffect(() => {
signOut && authenticationContext.signOut(false);
toast.success(message);
}, [message, signOut, authenticationContext]);
return <Navigate to="/" />;
};
const RootRedirect: FC<SecurityRedirectProps> = memo(
({ message, signOut = false }) => {
const { signOut: contextSignOut } = useContext(AuthenticationContext);
const hasShownToast = useRef(false);
const AppRouting = () => {
useEffect(() => {
// Prevent duplicate toasts on strict mode or re-renders
if (!hasShownToast.current) {
hasShownToast.current = true;
if (signOut) {
contextSignOut(false);
}
toast.success(message);
}
// Only run once on mount - using ref to track execution
}, []);
return <Navigate to="/" replace />;
}
);
const AppRouting: FC = memo(() => {
const { LL } = useI18nContext();
return (
<Authentication>
<Routes>
<Route
path="/unauthorized"
element={<RootRedirect message={LL.PLEASE_SIGNIN()} signOut />}
/>
<Route
path="/fileUpdated"
element={<RootRedirect message={LL.UPLOAD_SUCCESSFUL()} />}
/>
<Route
path="/"
element={
<RequireUnauthenticated>
<SignIn />
</RequireUnauthenticated>
}
/>
<Route
path="/*"
element={
<RequireAuthenticated>
<AuthenticatedRouting />
</RequireAuthenticated>
}
/>
</Routes>
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route
path="/unauthorized"
element={<RootRedirect message={LL.PLEASE_SIGNIN()} signOut />}
/>
<Route
path="/fileUpdated"
element={<RootRedirect message={LL.UPLOAD_SUCCESSFUL()} />}
/>
<Route
path="/"
element={
<RequireUnauthenticated>
<SignIn />
</RequireUnauthenticated>
}
/>
<Route
path="/*"
element={
<RequireAuthenticated>
<AuthenticatedRouting />
</RequireAuthenticated>
}
/>
</Routes>
</Suspense>
</Authentication>
);
};
});
export default AppRouting;

View File

@@ -1,76 +1,88 @@
import { useContext } from 'react';
import { Suspense, lazy, memo, useContext } from 'react';
import { Navigate, Route, Routes } from 'react-router';
import CustomEntities from 'app/main/CustomEntities';
import Customizations from 'app/main/Customizations';
import Dashboard from 'app/main/Dashboard';
import Devices from 'app/main/Devices';
import Help from 'app/main/Help';
import Modules from 'app/main/Modules';
import Scheduler from 'app/main/Scheduler';
import Sensors from 'app/main/Sensors';
import APSettings from 'app/settings/APSettings';
import ApplicationSettings from 'app/settings/ApplicationSettings';
import DownloadUpload from 'app/settings/DownloadUpload';
import MqttSettings from 'app/settings/MqttSettings';
import NTPSettings from 'app/settings/NTPSettings';
import Settings from 'app/settings/Settings';
import Network from 'app/settings/network/Network';
import Security from 'app/settings/security/Security';
import APStatus from 'app/status/APStatus';
import Activity from 'app/status/Activity';
import HardwareStatus from 'app/status/HardwareStatus';
import MqttStatus from 'app/status/MqttStatus';
import NTPStatus from 'app/status/NTPStatus';
import NetworkStatus from 'app/status/NetworkStatus';
import Status from 'app/status/Status';
import SystemLog from 'app/status/SystemLog';
import Version from 'app/status/Version';
import { Layout } from 'components';
import { Layout, LoadingSpinner } from 'components';
import { AuthenticatedContext } from 'contexts/authentication';
const AuthenticatedRouting = () => {
// Lazy load all route components for better code splitting
const Dashboard = lazy(() => import('app/main/Dashboard'));
const Devices = lazy(() => import('app/main/Devices'));
const Sensors = lazy(() => import('app/main/Sensors'));
const Help = lazy(() => import('app/main/Help'));
const Customizations = lazy(() => import('app/main/Customizations'));
const Scheduler = lazy(() => import('app/main/Scheduler'));
const CustomEntities = lazy(() => import('app/main/CustomEntities'));
const Modules = lazy(() => import('app/main/Modules'));
const UserProfile = lazy(() => import('app/main/UserProfile'));
const Status = lazy(() => import('app/status/Status'));
const HardwareStatus = lazy(() => import('app/status/HardwareStatus'));
const Activity = lazy(() => import('app/status/Activity'));
const SystemLog = lazy(() => import('app/status/SystemLog'));
const MqttStatus = lazy(() => import('app/status/MqttStatus'));
const NTPStatus = lazy(() => import('app/status/NTPStatus'));
const APStatus = lazy(() => import('app/status/APStatus'));
const NetworkStatus = lazy(() => import('app/status/NetworkStatus'));
const Version = lazy(() => import('app/status/Version'));
const Settings = lazy(() => import('app/settings/Settings'));
const ApplicationSettings = lazy(() => import('app/settings/ApplicationSettings'));
const MqttSettings = lazy(() => import('app/settings/MqttSettings'));
const NTPSettings = lazy(() => import('app/settings/NTPSettings'));
const APSettings = lazy(() => import('app/settings/APSettings'));
const DownloadUpload = lazy(() => import('app/settings/DownloadUpload'));
const Network = lazy(() => import('app/settings/network/Network'));
const Security = lazy(() => import('app/settings/security/Security'));
const AuthenticatedRouting = memo(() => {
const { me } = useContext(AuthenticatedContext);
return (
<Layout>
<Routes>
<Route path="/dashboard/*" element={<Dashboard />} />
<Route path="/devices/*" element={<Devices />} />
<Route path="/sensors/*" element={<Sensors />} />
<Route path="/status/*" element={<Status />} />
<Route path="/help/*" element={<Help />} />
<Route path="/*" element={<Navigate to="/" />} />
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/dashboard/*" element={<Dashboard />} />
<Route path="/devices/*" element={<Devices />} />
<Route path="/sensors/*" element={<Sensors />} />
<Route path="/help/*" element={<Help />} />
<Route path="/user/*" element={<UserProfile />} />
<Route path="/status/hardwarestatus/*" element={<HardwareStatus />} />
<Route path="/status/activity" element={<Activity />} />
<Route path="/status/log" element={<SystemLog />} />
<Route path="/status/mqtt" element={<MqttStatus />} />
<Route path="/status/ntp" element={<NTPStatus />} />
<Route path="/status/ap" element={<APStatus />} />
<Route path="/status/network" element={<NetworkStatus />} />
<Route path="/status/version" element={<Version />} />
<Route path="/status/*" element={<Status />} />
<Route path="/status/hardwarestatus/*" element={<HardwareStatus />} />
<Route path="/status/activity" element={<Activity />} />
<Route path="/status/log" element={<SystemLog />} />
<Route path="/status/mqtt" element={<MqttStatus />} />
<Route path="/status/ntp" element={<NTPStatus />} />
<Route path="/status/ap" element={<APStatus />} />
<Route path="/status/network" element={<NetworkStatus />} />
<Route path="/status/version" element={<Version />} />
{me.admin && (
<>
<Route path="/settings" element={<Settings />} />
<Route path="/settings/application" element={<ApplicationSettings />} />
<Route path="/settings/mqtt" element={<MqttSettings />} />
<Route path="/settings/ntp" element={<NTPSettings />} />
<Route path="/settings/ap" element={<APSettings />} />
<Route path="/settings/modules" element={<Modules />} />
<Route path="/settings/downloadUpload" element={<DownloadUpload />} />
{me.admin && (
<>
<Route path="/settings" element={<Settings />} />
<Route
path="/settings/application"
element={<ApplicationSettings />}
/>
<Route path="/settings/mqtt" element={<MqttSettings />} />
<Route path="/settings/ntp" element={<NTPSettings />} />
<Route path="/settings/ap" element={<APSettings />} />
<Route path="/settings/modules" element={<Modules />} />
<Route path="/settings/downloadUpload" element={<DownloadUpload />} />
<Route path="/settings/network/*" element={<Network />} />
<Route path="/settings/security/*" element={<Security />} />
<Route path="/settings/network/*" element={<Network />} />
<Route path="/settings/security/*" element={<Security />} />
<Route path="/customizations" element={<Customizations />} />
<Route path="/scheduler" element={<Scheduler />} />
<Route path="/customentities" element={<CustomEntities />} />
</>
)}
</Routes>
<Route path="/customizations" element={<Customizations />} />
<Route path="/scheduler" element={<Scheduler />} />
<Route path="/customentities" element={<CustomEntities />} />
</>
)}
<Route path="/*" element={<Navigate to="/" />} />
</Routes>
</Suspense>
</Layout>
);
};
});
export default AuthenticatedRouting;

View File

@@ -1,6 +1,12 @@
import { memo } from 'react';
import type { FC } from 'react';
import { CssBaseline, ThemeProvider, responsiveFontSizes } from '@mui/material';
import {
CssBaseline,
ThemeProvider,
responsiveFontSizes,
tooltipClasses
} from '@mui/material';
import { createTheme } from '@mui/material/styles';
import type { RequiredChildrenProps } from 'utils';
@@ -10,9 +16,9 @@ export const dialogStyle = {
borderRadius: '8px',
borderColor: '#565656',
borderStyle: 'solid',
borderWidth: '1px'
borderWidth: '2px'
}
};
} as const;
const theme = responsiveFontSizes(
createTheme({
@@ -30,15 +36,45 @@ const theme = responsiveFontSizes(
text: {
disabled: '#eee' // white
}
},
components: {
MuiListItemText: {
styleOverrides: {
primary: {
fontSize: 14
},
secondary: {
color: '#9e9e9e' // grey[500]
}
}
},
MuiTooltip: {
defaultProps: {
placement: 'top',
arrow: true
},
styleOverrides: {
tooltip: {
padding: '4px 8px',
fontSize: 10,
color: 'rgba(0, 0, 0, 0.87)',
backgroundColor: '#4caf50', // MUI success.main default color
boxShadow: '0px 2px 8px rgba(0, 0, 0, 0.15)',
[`& .${tooltipClasses.arrow}`]: {
color: '#4caf50'
}
}
}
}
}
})
);
const CustomTheme: FC<RequiredChildrenProps> = ({ children }) => (
const CustomTheme: FC<RequiredChildrenProps> = memo(({ children }) => (
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
);
));
export default CustomTheme;

View File

@@ -1,4 +1,4 @@
import { useContext, useState } from 'react';
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import ForwardIcon from '@mui/icons-material/Forward';
@@ -19,7 +19,7 @@ import type { SignInRequest } from 'types';
import { onEnterCallback, updateValue } from 'utils';
import { SIGN_IN_REQUEST_VALIDATOR, validate } from 'validators';
const SignIn = () => {
const SignIn = memo(() => {
const authenticationContext = useContext(AuthenticationContext);
const { LL } = useI18nContext();
@@ -42,9 +42,18 @@ const SignIn = () => {
}
});
const updateLoginRequestValue = updateValue(setSignInRequest);
// Memoize callback to prevent recreation on every render
const updateLoginRequestValue = useMemo(
() =>
updateValue((updater) =>
setSignInRequest(
updater as unknown as (prevState: SignInRequest) => SignInRequest
)
),
[]
);
const signIn = async () => {
const signIn = useCallback(async () => {
await callSignIn(signInRequest).catch((event: Error) => {
if (event.message === 'Unauthorized') {
toast.warning(LL.INVALID_LOGIN());
@@ -53,9 +62,9 @@ const SignIn = () => {
}
setProcessing(false);
});
};
}, [callSignIn, signInRequest, LL]);
const validateAndSignIn = async () => {
const validateAndSignIn = useCallback(async () => {
setProcessing(true);
SIGN_IN_REQUEST_VALIDATOR.messages({
required: LL.IS_REQUIRED('%s')
@@ -67,9 +76,19 @@ const SignIn = () => {
setFieldErrors(error as ValidateFieldsError);
setProcessing(false);
}
};
}, [signInRequest, signIn, LL]);
const submitOnEnter = onEnterCallback(signIn);
// Memoize callback to prevent recreation on every render
const submitOnEnter = useMemo(() => onEnterCallback(signIn), [signIn]);
// get rid of scrollbar
useEffect(() => {
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = originalOverflow;
};
}, []);
return (
<Box
@@ -92,23 +111,27 @@ const SignIn = () => {
width: '100%'
})}
>
<Typography variant="h4">{PROJECT_NAME}</Typography>
<Typography mb={1} variant="h4">
{PROJECT_NAME}
</Typography>
<LanguageSelector />
<Box display="flex" flexDirection="column" alignItems="center">
<Box
mt={1}
display="flex"
flexDirection="column"
gap={1}
alignItems="center"
>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
disabled={processing}
sx={{
width: 240
width: '32ch'
}}
name="username"
label={LL.USERNAME(0)}
value={signInRequest.username}
onChange={updateLoginRequestValue}
margin="normal"
variant="outlined"
slotProps={{
input: {
autoCapitalize: 'none',
@@ -117,17 +140,16 @@ const SignIn = () => {
}}
/>
<ValidatedPasswordField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
disabled={processing}
sx={{
width: 240
width: '32ch'
}}
name="password"
label={LL.PASSWORD()}
value={signInRequest.password}
onChange={updateLoginRequestValue}
onKeyDown={submitOnEnter}
variant="outlined"
/>
</Box>
@@ -144,6 +166,6 @@ const SignIn = () => {
</Paper>
</Box>
);
};
});
export default SignIn;

View File

@@ -20,19 +20,18 @@ import type {
WriteTemperatureSensor
} from '../app/main/types';
const MSGPACK_CONFIG = { responseType: 'arraybuffer' as const };
// Dashboard
export const readDashboard = () =>
alovaInstance.Get<DashboardData>('/rest/dashboardData', {
responseType: 'arraybuffer' // uses msgpack
});
alovaInstance.Get<DashboardData>('/rest/dashboardData', MSGPACK_CONFIG);
// Devices
export const readCoreData = () => alovaInstance.Get<CoreData>(`/rest/coreData`);
export const readCoreData = () => alovaInstance.Get<CoreData>('/rest/coreData');
export const readDeviceData = (id: number) =>
alovaInstance.Get<DeviceData>('/rest/deviceData', {
// alovaInstance.Get<DeviceData>(`/rest/deviceData/${id}`, {
params: { id },
responseType: 'arraybuffer' // uses msgpack
...MSGPACK_CONFIG
});
export const writeDeviceValue = (data: { id: number; c: string; v: unknown }) =>
alovaInstance.Post('/rest/writeDeviceValue', data);
@@ -66,12 +65,13 @@ export const callAction = (action: Action) =>
// SettingsCustomization
export const readDeviceEntities = (id: number) =>
// alovaInstance.Get<DeviceEntity[]>(`/rest/deviceEntities/${id}`, {
alovaInstance.Get<DeviceEntity[]>(`/rest/deviceEntities`, {
alovaInstance.Get<DeviceEntity[]>('/rest/deviceEntities', {
params: { id },
responseType: 'arraybuffer',
...MSGPACK_CONFIG,
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue
transform(data) {
return (data as DeviceEntity[]).map((de: DeviceEntity) => ({
const entities = data as DeviceEntity[];
return entities.map((de) => ({
...de,
o_m: de.m,
o_cn: de.cn,
@@ -92,8 +92,10 @@ export const writeDeviceName = (data: { id: number; name: string }) =>
// SettingsScheduler
export const readSchedule = () =>
alovaInstance.Get<ScheduleItem[]>('/rest/schedule', {
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue
transform(data) {
return (data as Schedule).schedule.map((si: ScheduleItem) => ({
const schedule = (data as Schedule).schedule;
return schedule.map((si) => ({
...si,
o_id: si.id,
o_active: si.active,
@@ -113,7 +115,8 @@ export const writeSchedule = (data: Schedule) =>
export const readModules = () =>
alovaInstance.Get<ModuleItem[]>('/rest/modules', {
transform(data) {
return (data as Modules).modules.map((mi: ModuleItem) => ({
const modules = (data as Modules).modules;
return modules.map((mi) => ({
...mi,
o_enabled: mi.enabled,
o_license: mi.license
@@ -129,8 +132,10 @@ export const writeModules = (data: {
// CustomEntities
export const readCustomEntities = () =>
alovaInstance.Get<EntityItem[]>('/rest/customEntities', {
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue
transform(data) {
return (data as Entities).entities.map((ei: EntityItem) => ({
const entities = (data as Entities).entities;
return entities.map((ei) => ({
...ei,
o_id: ei.id,
o_ram: ei.ram,
@@ -143,7 +148,8 @@ export const readCustomEntities = () =>
o_name: ei.name,
o_writeable: ei.writeable,
o_value: ei.value,
o_deleted: ei.deleted
o_deleted: ei.deleted,
o_hide: ei.hide
}));
}
});

View File

@@ -4,55 +4,57 @@ import ReactHook from 'alova/react';
import { unpack } from './unpack';
export const ACCESS_TOKEN = 'access_token';
export const ACCESS_TOKEN = 'access_token' as const;
// Cached token to avoid repeated localStorage access
let cachedToken: string | null = null;
const getAccessToken = (): string | null => {
if (cachedToken === null) {
cachedToken = localStorage.getItem(ACCESS_TOKEN);
}
return cachedToken;
};
// Clear token cache when needed (e.g., on logout)
export const clearTokenCache = (): void => {
cachedToken = null;
};
const handleResponse = async (response: AlovaXHRResponse) => {
// Handle various HTTP status codes
if (response.status === 205) {
throw new Error('Reboot required');
}
if (response.status === 400) {
throw new Error('Request Failed');
}
if (response.status >= 400) {
throw new Error(response.statusText);
}
const data = (await response.data) as ArrayBuffer;
// Unpack MessagePack data if ArrayBuffer
if (data instanceof ArrayBuffer) {
return unpack(data) as ArrayBuffer;
}
return data;
};
export const alovaInstance = createAlova({
statesHook: ReactHook,
// timeout: 3000, // 3 seconds before throwing a timeout error, default is 0 = none
cacheFor: null, // disable cache
// cacheFor: {
// GET: {
// mode: 'memory',
// expire: 60 * 10 * 1000 // 60 seconds in cache
// }
// },
requestAdapter: xhrRequestAdapter(),
beforeRequest(method) {
if (localStorage.getItem(ACCESS_TOKEN)) {
method.config.headers.Authorization =
'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
const token = getAccessToken();
if (token) {
method.config.headers.Authorization = `Bearer ${token}`;
}
// for simulating very slow networks
// return new Promise((resolve) => {
// const random = 3000 + Math.random() * 2000;
// setTimeout(resolve, Math.floor(random));
// });
},
responded: {
onSuccess: async (response: AlovaXHRResponse) => {
// if (response.status === 202) {
// throw new Error('Wait'); // wifi scan in progress
// } else
if (response.status === 205) {
throw new Error('Reboot required');
} else if (response.status === 400) {
throw new Error('Request Failed');
} else if (response.status >= 400) {
throw new Error(response.statusText);
}
const data: ArrayBuffer = (await response.data) as ArrayBuffer;
if (response.data instanceof ArrayBuffer) {
return unpack(data) as ArrayBuffer;
}
return data;
}
// Interceptor for request failure. This interceptor will be entered when the request is wrong.
// http errors like 401 (unauthorized) are handled either in the methods or AuthenticatedRouting()
// onError: (error, method) => {
// alert(error.message);
// }
onSuccess: handleResponse
}
});

View File

@@ -2,12 +2,14 @@ import type { NetworkSettingsType, NetworkStatusType, WiFiNetworkList } from 'ty
import { alovaInstance } from './endpoints';
const LIST_NETWORKS_TIMEOUT = 20000; // 20 seconds
export const readNetworkStatus = () =>
alovaInstance.Get<NetworkStatusType>('/rest/networkStatus');
export const scanNetworks = () => alovaInstance.Get('/rest/scanNetworks');
export const listNetworks = () =>
alovaInstance.Get<WiFiNetworkList>('/rest/listNetworks', {
timeout: 20000 // 20 seconds
timeout: LIST_NETWORKS_TIMEOUT
});
export const readNetworkSettings = () =>
alovaInstance.Get<NetworkSettingsType>('/rest/networkSettings');

View File

@@ -6,7 +6,7 @@ export const readNTPStatus = () =>
alovaInstance.Get<NTPStatusType>('/rest/ntpStatus');
export const readNTPSettings = () =>
alovaInstance.Get<NTPSettingsType>('/rest/ntpSettings', {});
alovaInstance.Get<NTPSettingsType>('/rest/ntpSettings');
export const updateNTPSettings = (data: NTPSettingsType) =>
alovaInstance.Post<NTPSettingsType>('/rest/ntpSettings', data);

View File

@@ -8,7 +8,7 @@ export const readSystemStatus = () =>
// SystemLog
export const readLogSettings = () =>
alovaInstance.Get<LogSettings>(`/rest/logSettings`);
alovaInstance.Get<LogSettings>('/rest/logSettings');
export const updateLogSettings = (data: LogSettings) =>
alovaInstance.Post('/rest/logSettings', data);
export const fetchLogES = () => alovaInstance.Get('/es/log');
@@ -30,16 +30,18 @@ export const getDevVersion = () =>
cacheFor: 60 * 10 * 1000,
transform(response: { data: { name: string; published_at: string } }) {
return {
name: response.data.name.split(/\s+/).splice(-1)[0].substring(1),
name: response.data.name.split(/\s+/).splice(-1)[0]?.substring(1) || '',
published_at: response.data.published_at
};
}
});
const UPLOAD_TIMEOUT = 60000; // 1 minute
export const uploadFile = (file: File) => {
const formData = new FormData();
formData.append('file', file);
return alovaInstance.Post('/rest/uploadFile', formData, {
timeout: 60000 // override timeout for uploading firmware - 1 minute
timeout: UPLOAD_TIMEOUT
});
};

View File

@@ -1,40 +1,37 @@
let decoder;
// @ts-nocheck - Optimized MessagePack unpacking library for EMS-ESP32
let decoder,
src,
srcEnd,
position = 0,
strings = [],
stringPosition = 0,
currentUnpackr = {},
currentStructures,
srcString,
srcStringStart = 0,
srcStringEnd = 0,
bundledStrings,
referenceMap,
dataView;
const EMPTY_ARRAY = [],
currentExtensions = [];
const defaultOptions = { useRecords: false, mapsAsObjects: true };
try {
decoder = new TextDecoder();
} catch (error) {}
let src;
let srcEnd;
let position = 0;
const EMPTY_ARRAY = [];
let strings = EMPTY_ARRAY;
let stringPosition = 0;
let currentUnpackr = {};
let currentStructures;
let srcString;
let srcStringStart = 0;
let srcStringEnd = 0;
let bundledStrings;
let referenceMap;
const currentExtensions = [];
let dataView;
const defaultOptions = {
useRecords: false,
mapsAsObjects: true
};
export class C1Type {}
export const C1 = new C1Type();
class C1Type {}
const C1 = new C1Type();
C1.name = 'MessagePack 0xC1';
let sequentialMode = false;
let inlineObjectReadThreshold = 2;
let readStruct, onLoadedStructures, onSaveState;
// no-eval build
let sequentialMode = false,
inlineObjectReadThreshold = 2,
readStruct,
onLoadedStructures,
onSaveState;
try {
new Function('');
} catch (error) {
// if eval variants are not supported, do not create inline object readers ever
inlineObjectReadThreshold = Infinity;
}
export class Unpackr {
constructor(options) {
if (options) {
@@ -50,19 +47,15 @@ export class Unpackr {
if (options.structures)
options.structures.sharedLength = options.structures.length;
else if (options.getStructures) {
(options.structures = []).uninitialized = true; // this is what we use to denote an uninitialized structures
(options.structures = []).uninitialized = true;
options.structures.sharedLength = 0;
}
if (options.int64AsNumber) {
options.int64AsType = 'number';
}
if (options.int64AsNumber) options.int64AsType = 'number';
}
Object.assign(this, options);
}
unpack(source, options?: any) {
unpack(source, options?: { start?: number; end?: number; lazy?: boolean }) {
if (src) {
// re-entrant execution, save the state and restore it after we do this unpack
return saveState(() => {
clearSource();
return this
@@ -86,9 +79,6 @@ export class Unpackr {
strings = EMPTY_ARRAY;
bundledStrings = null;
src = source;
// this provides cached access to the data view for a buffer if it is getting reused, which is a recommend
// technique for getting data from a database where it can be copied into an existing buffer instead of creating
// new ones
try {
dataView =
source.dataView ||
@@ -191,10 +181,10 @@ export class Unpackr {
return this.unpack(source, end);
}
}
export function getPosition() {
function getPosition() {
return position;
}
export function checkedRead(options: any) {
function checkedRead(options?: { lazy?: boolean }) {
try {
if (!currentUnpackr.trusted && !sequentialMode) {
const sharedLength = currentStructures.sharedLength || 0;
@@ -264,7 +254,7 @@ function restoreStructures() {
currentStructures.restoreStructures = null;
}
export function read() {
function read() {
let token = src[position++];
if (token < 0xa0) {
if (token < 0x80) {
@@ -589,7 +579,7 @@ const createSecondByteReader = (firstId, read0) =>
return structure.read();
};
export function loadStructures() {
function loadStructures() {
const loadedStructures = saveState(() => {
// save the state in case getStructures modifies our buffer
src = null;
@@ -605,9 +595,8 @@ var readFixedString = readStringJS;
var readString8 = readStringJS;
var readString16 = readStringJS;
var readString32 = readStringJS;
export let isNativeAccelerationEnabled = false;
export function setExtractor(extractStrings) {
let isNativeAccelerationEnabled = false;
function setExtractor(extractStrings) {
isNativeAccelerationEnabled = true;
readFixedString = readString(1);
readString8 = readString(2);
@@ -701,7 +690,7 @@ function readStringJS(length) {
return result;
}
export function readString(source, start, length) {
function readString(source, start, length) {
const existingSrc = src;
src = source;
position = start;
@@ -1065,7 +1054,7 @@ currentExtensions[0x70] = (data) => {
currentExtensions[0x73] = () => new Set(read());
export const typedArrays = [
const typedArrays = [
'Int8',
'Uint8',
'Uint8Clamped',
@@ -1177,44 +1166,20 @@ function saveState(callback) {
dataView = new DataView(src.buffer, src.byteOffset, src.byteLength);
return value;
}
export function clearSource() {
function clearSource() {
src = null;
referenceMap = null;
currentStructures = null;
}
export function addExtension(extension) {
function addExtension(extension) {
if (extension.unpack) currentExtensions[extension.type] = extension.unpack;
else currentExtensions[extension.type] = extension;
}
export const mult10 = new Array(147); // this is a table matching binary exponents to the multiplier to determine significant digit rounding
const mult10 = new Array(147);
for (let i = 0; i < 256; i++) {
mult10[i] = +('1e' + Math.floor(45.15 - i * 0.30103));
}
export const Decoder = Unpackr;
var defaultUnpackr = new Unpackr({ useRecords: false });
const defaultUnpackr = new Unpackr({ useRecords: false });
export const unpack = defaultUnpackr.unpack;
export const unpackMultiple = defaultUnpackr.unpackMultiple;
export const decode = defaultUnpackr.unpack;
export const FLOAT32_OPTIONS = {
NEVER: 0,
ALWAYS: 1,
DECIMAL_ROUND: 3,
DECIMAL_FIT: 4
};
const f32Array = new Float32Array(1);
const u8Array = new Uint8Array(f32Array.buffer, 0, 4);
export function roundFloat32(float32Number) {
f32Array[0] = float32Number;
const multiplier = mult10[((u8Array[3] & 0x7f) << 1) | (u8Array[2] >> 7)];
return (
((multiplier * float32Number + (float32Number > 0 ? 0.5 : -0.5)) >> 0) /
multiplier
);
}
export function setReadStruct(updatedReadStruct, loadedStructs, saveState) {
readStruct = updatedReadStruct;
onLoadedStructures = loadedStructs;
onSaveState = saveState;
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useBlocker } from 'react-router';
import { toast } from 'react-toastify';
@@ -35,6 +35,10 @@ import { DeviceValueTypeNames, DeviceValueUOM_s } from './types';
import type { Entities, EntityItem } from './types';
import { entityItemValidation } from './validators';
const MIN_ID = -100;
const MAX_ID = 100;
const ICON_SIZE = 12;
const CustomEntities = () => {
const { LL } = useI18nContext();
const [numChanges, setNumChanges] = useState<number>(0);
@@ -53,18 +57,20 @@ const CustomEntities = () => {
initialData: []
});
useInterval(() => {
const intervalCallback = useCallback(() => {
if (!dialogOpen && !numChanges) {
void fetchEntities();
}
});
}, [dialogOpen, numChanges, fetchEntities]);
useInterval(intervalCallback);
const { send: writeEntities } = useRequest(
(data: Entities) => writeCustomEntities(data),
{ immediate: false }
);
function hasEntityChanged(ei: EntityItem) {
const hasEntityChanged = useCallback((ei: EntityItem) => {
return (
ei.id !== ei.o_id ||
ei.ram !== ei.o_ram ||
@@ -76,22 +82,25 @@ const CustomEntities = () => {
ei.factor !== ei.o_factor ||
ei.value_type !== ei.o_value_type ||
ei.writeable !== ei.o_writeable ||
ei.hide !== ei.o_hide ||
ei.deleted !== ei.o_deleted ||
(ei.value || '') !== (ei.o_value || '')
);
}
}, []);
const entity_theme = useTheme({
Table: `
const entity_theme = useMemo(
() =>
useTheme({
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(60px, 1fr)) minmax(80px, auto) 80px 80px 80px 120px;
`,
BaseRow: `
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
BaseCell: `
&:nth-of-type(1) {
padding: 8px;
}
@@ -111,7 +120,7 @@ const CustomEntities = () => {
text-align: center;
}
`,
HeaderRow: `
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
@@ -120,7 +129,7 @@ const CustomEntities = () => {
height: 36px;
}
`,
Row: `
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
@@ -131,13 +140,15 @@ const CustomEntities = () => {
background-color: #177ac9;
}
`
});
}),
[]
);
const saveEntities = async () => {
const saveEntities = useCallback(async () => {
await writeEntities({
entities: entities
.filter((ei) => !ei.deleted)
.map((condensed_ei) => ({
.filter((ei: EntityItem) => !ei.deleted)
.map((condensed_ei: EntityItem) => ({
id: condensed_ei.id,
ram: condensed_ei.ram,
name: condensed_ei.name,
@@ -147,6 +158,7 @@ const CustomEntities = () => {
factor: condensed_ei.factor,
uom: condensed_ei.uom,
writeable: condensed_ei.writeable,
hide: condensed_ei.hide,
value_type: condensed_ei.value_type,
value: condensed_ei.value
}))
@@ -161,7 +173,7 @@ const CustomEntities = () => {
await fetchEntities();
setNumChanges(0);
});
};
}, [entities, writeEntities, LL, fetchEntities]);
const editEntityItem = useCallback((ei: EntityItem) => {
setCreating(false);
@@ -169,36 +181,39 @@ const CustomEntities = () => {
setDialogOpen(true);
}, []);
const onDialogClose = () => {
const onDialogClose = useCallback(() => {
setDialogOpen(false);
};
}, []);
const onDialogCancel = async () => {
const onDialogCancel = useCallback(async () => {
await fetchEntities().then(() => {
setNumChanges(0);
});
};
}, [fetchEntities]);
const onDialogSave = (updatedItem: EntityItem) => {
setDialogOpen(false);
void updateState(readCustomEntities(), (data: EntityItem[]) => {
const new_data = creating
? [
...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id),
updatedItem
]
: data.map((ei) =>
ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei
);
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
return new_data;
});
};
const onDialogSave = useCallback(
(updatedItem: EntityItem) => {
setDialogOpen(false);
void updateState(readCustomEntities(), (data: EntityItem[]) => {
const new_data = creating
? [
...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id),
updatedItem
]
: data.map((ei) =>
ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei
);
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
return new_data;
});
},
[creating, hasEntityChanged]
);
const onDialogDup = (item: EntityItem) => {
const onDialogDup = useCallback((item: EntityItem) => {
setCreating(true);
setSelectedEntityItem({
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
name: item.name + '_',
ram: item.ram,
device_id: item.device_id,
@@ -209,15 +224,16 @@ const CustomEntities = () => {
value_type: item.value_type,
writeable: item.writeable,
deleted: false,
hide: item.hide,
value: item.value
});
setDialogOpen(true);
};
}, []);
const addEntityItem = () => {
const addEntityItem = useCallback(() => {
setCreating(true);
setSelectedEntityItem({
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
name: '',
ram: 0,
device_id: '0',
@@ -228,35 +244,44 @@ const CustomEntities = () => {
value_type: 0,
writeable: false,
deleted: false,
hide: false,
value: ''
});
setDialogOpen(true);
};
}, []);
function formatValue(value: unknown, uom: number) {
const formatValue = useCallback((value: unknown, uom: number) => {
return value === undefined
? ''
: typeof value === 'number'
? new Intl.NumberFormat().format(value) +
(uom === 0 ? '' : ' ' + DeviceValueUOM_s[uom])
: (value as string) + (uom === 0 ? '' : ' ' + DeviceValueUOM_s[uom]);
}
(uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`)
: `${value as string}${uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`}`;
}, []);
function showHex(value: number, digit: number) {
return '0x' + value.toString(16).toUpperCase().padStart(digit, '0');
}
const showHex = useCallback((value: number, digit: number) => {
return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`;
}, []);
const renderEntity = () => {
const filteredAndSortedEntities = useMemo(
() =>
entities
?.filter((ei: EntityItem) => !ei.deleted)
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [],
[entities]
);
const renderEntity = useCallback(() => {
if (!entities) {
return <FormLoader onRetry={fetchEntities} errorMessage={error?.message} />;
return (
<FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} />
);
}
return (
<Table
data={{
nodes: entities
.filter((ei) => !ei.deleted)
.sort((a, b) => a.name.localeCompare(b.name))
nodes: filteredAndSortedEntities
}}
theme={entity_theme}
layout={{ custom: true }}
@@ -279,7 +304,10 @@ const CustomEntities = () => {
<Cell>
{ei.name}&nbsp;
{ei.writeable && (
<EditOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
<EditOutlinedIcon
color="primary"
sx={{ fontSize: ICON_SIZE }}
/>
)}
</Cell>
<Cell>
@@ -298,7 +326,17 @@ const CustomEntities = () => {
)}
</Table>
);
};
}, [
entities,
error,
fetchEntities,
entity_theme,
editEntityItem,
LL,
filteredAndSortedEntities,
showHex,
formatValue
]);
return (
<SectionContent>
@@ -321,7 +359,7 @@ const CustomEntities = () => {
/>
)}
<Box mt={1} display="flex" flexWrap="wrap">
<Box mt={2} display="flex" flexWrap="wrap">
<Box flexGrow={1}>
{numChanges > 0 && (
<ButtonRow>

View File

@@ -1,8 +1,12 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined';
import DoneIcon from '@mui/icons-material/Done';
import EditOffOutlinedIcon from '@mui/icons-material/EditOffOutlined';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import InsertCommentOutlinedIcon from '@mui/icons-material/InsertCommentOutlined';
import RemoveIcon from '@mui/icons-material/RemoveCircleOutline';
import {
Box,
@@ -12,7 +16,7 @@ import {
DialogActions,
DialogContent,
DialogTitle,
Grid2 as Grid,
Grid,
InputAdornment,
MenuItem,
TextField
@@ -29,6 +33,19 @@ import { validate } from 'validators';
import { DeviceValueType, DeviceValueTypeNames, DeviceValueUOM_s } from './types';
import type { EntityItem } from './types';
// Constant value type options for the dropdown
const VALUE_TYPE_OPTIONS = [
DeviceValueType.BOOL,
DeviceValueType.INT8,
DeviceValueType.UINT8,
DeviceValueType.INT16,
DeviceValueType.UINT16,
DeviceValueType.UINT24,
DeviceValueType.TIME,
DeviceValueType.UINT32,
DeviceValueType.STRING
] as const;
interface CustomEntitiesDialogProps {
open: boolean;
creating: boolean;
@@ -51,61 +68,97 @@ const CustomEntitiesDialog = ({
const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<EntityItem>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValue(setEditItem);
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
);
useEffect(() => {
if (open) {
setFieldErrors(undefined);
setEditItem(selectedItem);
// convert to hex strings straight away
// Convert to hex strings - combined into single setEditItem call
const deviceIdHex =
typeof selectedItem.device_id === 'number'
? selectedItem.device_id.toString(16).toUpperCase()
: selectedItem.device_id;
const typeIdHex =
typeof selectedItem.type_id === 'number'
? selectedItem.type_id.toString(16).toUpperCase()
: selectedItem.type_id;
const factorValue =
selectedItem.value_type === DeviceValueType.BOOL &&
typeof selectedItem.factor === 'number'
? selectedItem.factor.toString(16).toUpperCase()
: selectedItem.factor;
setEditItem({
...selectedItem,
device_id: selectedItem.device_id.toString(16).toUpperCase(),
type_id: selectedItem.type_id.toString(16).toUpperCase(),
factor:
selectedItem.value_type === DeviceValueType.BOOL
? selectedItem.factor.toString(16).toUpperCase()
: selectedItem.factor
device_id: deviceIdHex,
type_id: typeIdHex,
factor: factorValue
});
}
}, [open, selectedItem]);
const handleClose = (_event, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
};
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
},
[onClose]
);
const save = async () => {
const save = useCallback(async () => {
try {
setFieldErrors(undefined);
await validate(validator, editItem);
if (typeof editItem.device_id === 'string') {
editItem.device_id = parseInt(editItem.device_id, 16);
// Create a copy to avoid mutating the state directly
const processedItem: EntityItem = { ...editItem };
if (typeof processedItem.device_id === 'string') {
processedItem.device_id = Number.parseInt(processedItem.device_id, 16);
}
if (typeof editItem.type_id === 'string') {
editItem.type_id = parseInt(editItem.type_id, 16);
if (typeof processedItem.type_id === 'string') {
processedItem.type_id = Number.parseInt(processedItem.type_id, 16);
}
if (
editItem.value_type === DeviceValueType.BOOL &&
typeof editItem.factor === 'string'
processedItem.value_type === DeviceValueType.BOOL &&
typeof processedItem.factor === 'string'
) {
editItem.factor = parseInt(editItem.factor, 16);
processedItem.factor = Number.parseInt(processedItem.factor, 16);
}
onSave(editItem);
onSave(processedItem);
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
};
}, [validator, editItem, onSave]);
const remove = () => {
editItem.deleted = true;
onSave(editItem);
};
const remove = useCallback(() => {
const itemWithDeleted = { ...editItem, deleted: true };
onSave(itemWithDeleted);
}, [editItem, onSave]);
const dup = () => {
const dup = useCallback(() => {
onDup(editItem);
};
}, [editItem, onDup]);
// Memoize UOM menu items to avoid recreating on every render
const uomMenuItems = useMemo(
() =>
DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
)),
[]
);
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
@@ -113,13 +166,10 @@ const CustomEntitiesDialog = ({
{creating ? LL.ADD(1) + ' ' + LL.NEW(1) : LL.EDIT()}&nbsp;{LL.ENTITY()}
</DialogTitle>
<DialogContent dividers>
<Box display="flex" flexWrap="wrap" mb={1}>
<Box flexWrap="nowrap" whiteSpace="nowrap" />
</Box>
<Grid container spacing={2} rowSpacing={0}>
<Grid size={12}>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="name"
label={LL.NAME(0)}
value={editItem.name}
@@ -128,6 +178,20 @@ const CustomEntitiesDialog = ({
onChange={updateFormValue}
/>
</Grid>
<Grid mt={3}>
<BlockFormControlLabel
control={
<Checkbox
icon={<InsertCommentOutlinedIcon htmlColor="white" />}
checkedIcon={<CommentsDisabledOutlinedIcon color="primary" />}
checked={editItem.hide}
onChange={updateFormValue}
name="hide"
/>
}
label="API/MQTT"
/>
</Grid>
<Grid>
<TextField
name="ram"
@@ -166,21 +230,19 @@ const CustomEntitiesDialog = ({
onChange={updateFormValue}
select
>
{DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
))}
{uomMenuItems}
</TextField>
</Grid>
</>
)}
{editItem.ram === 0 && (
<>
<Grid mt={3} size={9}>
<Grid mt={3}>
<BlockFormControlLabel
control={
<Checkbox
icon={<EditOffOutlinedIcon color="primary" />}
checkedIcon={<EditOutlinedIcon htmlColor="white" />}
checked={editItem.writeable}
onChange={updateFormValue}
name="writeable"
@@ -191,7 +253,7 @@ const CustomEntitiesDialog = ({
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="device_id"
label={LL.ID_OF(LL.DEVICE())}
margin="normal"
@@ -211,7 +273,7 @@ const CustomEntitiesDialog = ({
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="type_id"
label={LL.ID_OF(LL.TYPE(1))}
margin="normal"
@@ -231,7 +293,7 @@ const CustomEntitiesDialog = ({
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="offset"
label={LL.OFFSET()}
margin="normal"
@@ -252,33 +314,11 @@ const CustomEntitiesDialog = ({
margin="normal"
select
>
<MenuItem value={DeviceValueType.BOOL}>
{DeviceValueTypeNames[DeviceValueType.BOOL]}
</MenuItem>
<MenuItem value={DeviceValueType.INT8}>
{DeviceValueTypeNames[DeviceValueType.INT8]}
</MenuItem>
<MenuItem value={DeviceValueType.UINT8}>
{DeviceValueTypeNames[DeviceValueType.UINT8]}
</MenuItem>
<MenuItem value={DeviceValueType.INT16}>
{DeviceValueTypeNames[DeviceValueType.INT16]}
</MenuItem>
<MenuItem value={DeviceValueType.UINT16}>
{DeviceValueTypeNames[DeviceValueType.UINT16]}
</MenuItem>
<MenuItem value={DeviceValueType.UINT24}>
{DeviceValueTypeNames[DeviceValueType.UINT24]}
</MenuItem>
<MenuItem value={DeviceValueType.TIME}>
{DeviceValueTypeNames[DeviceValueType.TIME]}
</MenuItem>
<MenuItem value={DeviceValueType.UINT32}>
{DeviceValueTypeNames[DeviceValueType.UINT32]}
</MenuItem>
<MenuItem value={DeviceValueType.STRING}>
{DeviceValueTypeNames[DeviceValueType.STRING]}
</MenuItem>
{VALUE_TYPE_OPTIONS.map((valueType) => (
<MenuItem key={valueType} value={valueType}>
{DeviceValueTypeNames[valueType]}
</MenuItem>
))}
</TextField>
</Grid>
@@ -310,11 +350,7 @@ const CustomEntitiesDialog = ({
onChange={updateFormValue}
select
>
{DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
))}
{uomMenuItems}
</TextField>
</Grid>
</>
@@ -323,7 +359,7 @@ const CustomEntitiesDialog = ({
editItem.device_id !== '0' && (
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="factor"
label={LL.BYTES()}
value={numberValue(editItem.factor as number)}
@@ -341,7 +377,7 @@ const CustomEntitiesDialog = ({
{editItem.value_type === DeviceValueType.BOOL && (
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="factor"
label={LL.BITMASK()}
value={editItem.factor as string}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useBlocker, useLocation } from 'react-router';
import { toast } from 'react-toastify';
@@ -16,7 +16,7 @@ import {
DialogActions,
DialogContent,
DialogTitle,
Grid2 as Grid,
Grid,
InputAdornment,
Link,
MenuItem,
@@ -62,7 +62,24 @@ import OptionIcon from './OptionIcon';
import { DeviceEntityMask } from './types';
import type { APIcall, Device, DeviceEntity } from './types';
export const APIURL = window.location.origin + '/api/';
export const APIURL = `${window.location.origin}/api/`;
const MAX_BUFFER_SIZE = 2000;
// Helper function to create masked entity ID - extracted to avoid duplication
const createMaskedEntityId = (de: DeviceEntity): string => {
const maskHex = de.m.toString(16).padStart(2, '0');
const hasCustomizations = !!(de.cn || de.mi || de.ma);
const customizations = [
de.cn || '',
de.mi ? `>${de.mi}` : '',
de.ma ? `<${de.ma}` : ''
]
.filter(Boolean)
.join('');
return `${maskHex}${de.id}${hasCustomizations ? `|${customizations}` : ''}`;
};
const Customizations = () => {
const { LL } = useI18nContext();
@@ -125,13 +142,22 @@ const Customizations = () => {
const setOriginalSettings = (data: DeviceEntity[]) => {
setDeviceEntities(
data.map((de) => ({
...de,
o_m: de.m,
o_cn: de.cn,
o_mi: de.mi,
o_ma: de.ma
}))
data.map((de) => {
const result: DeviceEntity = {
...de,
o_m: de.m
};
if (de.cn !== undefined) {
result.o_cn = de.cn;
}
if (de.mi !== undefined) {
result.o_mi = de.mi;
}
if (de.ma !== undefined) {
result.o_ma = de.ma;
}
return result;
})
);
};
@@ -144,17 +170,19 @@ const Customizations = () => {
);
};
const entities_theme = useTheme({
Table: `
const entities_theme = useMemo(
() =>
useTheme({
Table: `
--data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto);
`,
BaseRow: `
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
BaseCell: `
&:nth-of-type(3) {
text-align: right;
}
@@ -165,7 +193,7 @@ const Customizations = () => {
text-align: right;
}
`,
HeaderRow: `
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
@@ -177,7 +205,7 @@ const Customizations = () => {
text-align: center;
}
`,
Row: `
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
@@ -193,7 +221,7 @@ const Customizations = () => {
background-color: #177ac9;
}
`,
Cell: `
Cell: `
&:nth-of-type(2) {
padding: 8px;
}
@@ -207,7 +235,9 @@ const Customizations = () => {
padding-right: 8px;
}
`
});
}),
[]
);
function hasEntityChanged(de: DeviceEntity) {
return (
@@ -220,19 +250,8 @@ const Customizations = () => {
useEffect(() => {
if (deviceEntities.length) {
setNumChanges(
deviceEntities
.filter((de) => hasEntityChanged(de))
.map(
(new_de) =>
new_de.m.toString(16).padStart(2, '0') +
new_de.id +
(new_de.cn || new_de.mi || new_de.ma ? '|' : '') +
(new_de.cn ? new_de.cn : '') +
(new_de.mi ? '>' + new_de.mi : '') +
(new_de.ma ? '<' + new_de.ma : '')
).length
);
const changedEntities = deviceEntities.filter((de) => hasEntityChanged(de));
setNumChanges(changedEntities.length);
}
}, [deviceEntities]);
@@ -244,8 +263,11 @@ const Customizations = () => {
setSelectedDevice(-1);
setSelectedDeviceTypeNameURL('');
} else {
setSelectedDeviceTypeNameURL(devices.devices[index].url || '');
setSelectedDeviceName(devices.devices[index].n);
const device = devices.devices[index];
if (device) {
setSelectedDeviceTypeNameURL(device.url || '');
setSelectedDeviceName(device.n);
}
setNumChanges(0);
setRestartNeeded(false);
}
@@ -263,18 +285,26 @@ const Customizations = () => {
return value as string;
}
const formatName = (de: DeviceEntity, withShortname: boolean) =>
(de.n && de.n[0] === '!'
? de.t
? LL.COMMAND(1) + ': ' + de.t + ' ' + de.n.slice(1)
: LL.COMMAND(1) + ': ' + de.n.slice(1)
: de.cn && de.cn !== ''
? de.t
? de.t + ' ' + de.cn
: de.cn
: de.t
? de.t + ' ' + de.n
: de.n) + (withShortname ? ' ' + de.id : '');
const isCommand = useCallback((de: DeviceEntity) => {
return de.n && de.n[0] === '!';
}, []);
const formatName = useCallback(
(de: DeviceEntity, withShortname: boolean) => {
let name: string;
if (isCommand(de)) {
name = de.t
? `${LL.COMMAND(1)}: ${de.t} ${de.n?.slice(1)}`
: `${LL.COMMAND(1)}: ${de.n?.slice(1)}`;
} else if (de.cn && de.cn !== '') {
name = de.t ? `${de.t} ${de.cn}` : de.cn;
} else {
name = de.t ? `${de.t} ${de.n}` : de.n || '';
}
return withShortname ? `${name} ${de.id}` : name;
},
[LL]
);
const getMaskNumber = (newMask: string[]) => {
let new_mask = 0;
@@ -304,34 +334,33 @@ const Customizations = () => {
return new_masks;
};
const filter_entity = (de: DeviceEntity) =>
(de.m & selectedFilters || !selectedFilters) &&
formatName(de, true).includes(search);
const filter_entity = useCallback(
(de: DeviceEntity) =>
(de.m & selectedFilters || !selectedFilters) &&
formatName(de, true).toLowerCase().includes(search.toLowerCase()),
[selectedFilters, search, formatName]
);
const maskDisabled = (set: boolean) => {
setDeviceEntities(
deviceEntities.map(function (de) {
if (filter_entity(de)) {
return {
...de,
m: set
? de.m |
(DeviceEntityMask.DV_API_MQTT_EXCLUDE |
DeviceEntityMask.DV_WEB_EXCLUDE)
: de.m &
~(
DeviceEntityMask.DV_API_MQTT_EXCLUDE |
DeviceEntityMask.DV_WEB_EXCLUDE
)
};
} else {
const maskDisabled = useCallback(
(set: boolean) => {
setDeviceEntities((prev) =>
prev.map((de) => {
if (filter_entity(de)) {
const excludeMask =
DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
return {
...de,
m: set ? de.m | excludeMask : de.m & ~excludeMask
};
}
return de;
}
})
);
};
})
);
},
[filter_entity]
);
const resetCustomization = async () => {
const resetCustomization = useCallback(async () => {
try {
await sendResetCustomizations();
toast.info(LL.CUSTOMIZATIONS_RESTART());
@@ -339,25 +368,30 @@ const Customizations = () => {
toast.error((error as Error).message);
} finally {
setConfirmReset(false);
setRestarting(true);
}
};
}, [sendResetCustomizations, LL]);
const onDialogClose = () => {
setDialogOpen(false);
};
const updateDeviceEntity = (updatedItem: DeviceEntity) => {
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
setDeviceEntities(
deviceEntities?.map((de) =>
de.id === updatedItem.id ? { ...de, ...updatedItem } : de
)
(prev) =>
prev?.map((de) =>
de.id === updatedItem.id ? { ...de, ...updatedItem } : de
) ?? []
);
};
}, []);
const onDialogSave = (updatedItem: DeviceEntity) => {
setDialogOpen(false);
updateDeviceEntity(updatedItem);
};
const onDialogSave = useCallback(
(updatedItem: DeviceEntity) => {
setDialogOpen(false);
updateDeviceEntity(updatedItem);
},
[updateDeviceEntity]
);
const editDeviceEntity = useCallback((de: DeviceEntity) => {
if (de.n === undefined || (de.n && de.n[0] === '!')) {
@@ -372,54 +406,54 @@ const Customizations = () => {
setDialogOpen(true);
}, []);
const saveCustomization = async () => {
if (devices && deviceEntities && selectedDevice !== -1) {
const masked_entities = deviceEntities
.filter((de: DeviceEntity) => hasEntityChanged(de))
.map(
(new_de) =>
new_de.m.toString(16).padStart(2, '0') +
new_de.id +
(new_de.cn || new_de.mi || new_de.ma ? '|' : '') +
(new_de.cn ? new_de.cn : '') +
(new_de.mi ? '>' + new_de.mi : '') +
(new_de.ma ? '<' + new_de.ma : '')
);
const saveCustomization = useCallback(async () => {
if (!devices || !deviceEntities || selectedDevice === -1) {
return;
}
// check size in bytes to match buffer in CPP, which is 2048
const bytes = new TextEncoder().encode(JSON.stringify(masked_entities)).length;
if (bytes > 2000) {
toast.warning(LL.CUSTOMIZATIONS_FULL());
return;
}
const masked_entities = deviceEntities
.filter((de: DeviceEntity) => hasEntityChanged(de))
.map((new_de) => createMaskedEntityId(new_de));
await sendCustomizationEntities({
id: selectedDevice,
entity_ids: masked_entities
}).catch((error: Error) => {
// check size in bytes to match buffer in CPP, which is 2048
const bytes = new TextEncoder().encode(JSON.stringify(masked_entities)).length;
if (bytes > MAX_BUFFER_SIZE) {
toast.warning(LL.CUSTOMIZATIONS_FULL());
return;
}
await sendCustomizationEntities({
id: selectedDevice,
entity_ids: masked_entities
})
.then(() => {
toast.success(LL.CUSTOMIZATIONS_SAVED());
})
.catch((error: Error) => {
if (error.message === 'Reboot required') {
setRestartNeeded(true);
} else {
toast.error(error.message);
}
})
.finally(() => {
setOriginalSettings(deviceEntities);
});
setOriginalSettings(deviceEntities);
}
};
}, [devices, deviceEntities, selectedDevice, sendCustomizationEntities, LL]);
const renameDevice = async () => {
const renameDevice = useCallback(async () => {
await sendDeviceName({ id: selectedDevice, name: selectedDeviceName })
.then(() => {
toast.success(LL.UPDATED_OF(LL.NAME(1)));
})
.catch(() => {
toast.error(LL.UPDATE_OF(LL.NAME(1)) + ' ' + LL.FAILED(1));
toast.error(`${LL.UPDATE_OF(LL.NAME(1))} ${LL.FAILED(1)}`);
})
.finally(async () => {
setRename(false);
await fetchCoreData();
});
};
}, [selectedDevice, selectedDeviceName, sendDeviceName, LL, fetchCoreData]);
const renderDeviceList = () => (
<>
@@ -482,25 +516,38 @@ const Customizations = () => {
</Button>
</>
) : (
<Button
startIcon={<EditIcon />}
variant="outlined"
onClick={() => setRename(true)}
>
{LL.RENAME()}
</Button>
<>
<Button
startIcon={<EditIcon />}
variant="outlined"
onClick={() => setRename(true)}
>
{LL.RENAME()}
</Button>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
color="error"
onClick={() => setConfirmReset(true)}
>
{LL.REMOVE_ALL()}
</Button>
</>
))}
</Box>
</>
);
const renderDeviceData = () => {
const shown_data = deviceEntities.filter((de) => filter_entity(de));
const filteredEntities = useMemo(
() => deviceEntities.filter((de) => filter_entity(de)),
[deviceEntities, filter_entity]
);
const renderDeviceData = () => {
return (
<>
<Box color="warning.main">
<Typography variant="body2" mt={1}>
<Typography variant="body2" mt={1} mb={1}>
<OptionIcon type="favorite" isSet={true} />={LL.CUSTOMIZATIONS_HELP_2()}
&nbsp;&nbsp;
<OptionIcon type="readonly" isSet={true} />={LL.CUSTOMIZATIONS_HELP_3()}
@@ -526,6 +573,7 @@ const Customizations = () => {
size="small"
variant="outlined"
placeholder={LL.SEARCH()}
aria-label={LL.SEARCH()}
onChange={(event) => {
setSearch(event.target.value);
}}
@@ -545,7 +593,7 @@ const Customizations = () => {
size="small"
color="secondary"
value={getMaskString(selectedFilters)}
onChange={(event, mask: string[]) => {
onChange={(_, mask: string[]) => {
setSelectedFilters(getMaskNumber(mask));
}}
>
@@ -594,13 +642,13 @@ const Customizations = () => {
</Grid>
<Grid>
<Typography variant="subtitle2" color="grey">
{LL.SHOWING()}&nbsp;{shown_data.length}/{deviceEntities.length}
{LL.SHOWING()}&nbsp;{filteredEntities.length}/{deviceEntities.length}
&nbsp;{LL.ENTITIES(deviceEntities.length)}
</Typography>
</Grid>
</Grid>
<Table
data={{ nodes: shown_data }}
data={{ nodes: filteredEntities }}
theme={entities_theme}
layout={{ custom: true }}
>
@@ -622,14 +670,27 @@ const Customizations = () => {
<EntityMaskToggle onUpdate={updateDeviceEntity} de={de} />
</Cell>
<Cell>
{formatName(de, false)}&nbsp;(
<Link
target="_blank"
href={APIURL + selectedDeviceTypeNameURL + '/' + de.id}
<span
style={{
color:
de.v === undefined && !isCommand(de) ? 'grey' : 'inherit'
}}
>
{de.id}
</Link>
)
{formatName(de, false)}&nbsp;(
<Link
style={{
color:
de.v === undefined && !isCommand(de)
? 'grey'
: 'primary'
}}
target="_blank"
href={APIURL + selectedDeviceTypeNameURL + '/' + de.id}
>
{de.id}
</Link>
)
</span>
</Cell>
<Cell>
{!(de.m & DeviceEntityMask.DV_READONLY) && formatValue(de.mi)}
@@ -654,7 +715,7 @@ const Customizations = () => {
open={confirmReset}
onClose={() => setConfirmReset(false)}
>
<DialogTitle>{LL.RESET(1)}</DialogTitle>
<DialogTitle>{LL.REMOVE_ALL()}</DialogTitle>
<DialogContent dividers>{LL.CUSTOMIZATIONS_RESET()}</DialogContent>
<DialogActions>
<Button
@@ -671,7 +732,7 @@ const Customizations = () => {
onClick={resetCustomization}
color="error"
>
{LL.RESET(0)}
{LL.REMOVE_ALL()}
</Button>
</DialogActions>
</Dialog>
@@ -682,8 +743,9 @@ const Customizations = () => {
{devices && renderDeviceList()}
{selectedDevice !== -1 && !rename && renderDeviceData()}
{restartNeeded ? (
<MessageBox my={2} level="warning" message={LL.RESTART_TEXT(0)}>
<MessageBox level="warning" message={LL.RESTART_TEXT(0)}>
<Button
sx={{ ml: 2 }}
startIcon={<PowerSettingsNewIcon />}
variant="contained"
color="error"
@@ -701,7 +763,11 @@ const Customizations = () => {
startIcon={<CancelIcon />}
variant="outlined"
color="secondary"
onClick={() => devices && sendDeviceEntities(selectedDevice)}
onClick={() => {
if (devices) {
void sendDeviceEntities(selectedDevice);
}
}}
>
{LL.CANCEL()}
</Button>
@@ -716,18 +782,6 @@ const Customizations = () => {
</ButtonRow>
)}
</Box>
{!rename && (
<ButtonRow mt={1}>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
color="error"
onClick={() => setConfirmReset(true)}
>
{LL.RESET(0)}
</Button>
</ButtonRow>
)}
</Box>
)}
{renderResetDialog()}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import CloseIcon from '@mui/icons-material/Close';
@@ -10,7 +10,7 @@ import {
DialogActions,
DialogContent,
DialogTitle,
Grid2 as Grid,
Grid,
TextField,
Typography
} from '@mui/material';
@@ -30,6 +30,23 @@ interface SettingsCustomizationsDialogProps {
selectedItem: DeviceEntity;
}
interface LabelValueProps {
label: string;
value: React.ReactNode;
}
const LabelValue = memo(({ label, value }: LabelValueProps) => (
<Grid container direction="row">
<Typography variant="body2" color="warning.main">
{label}:&nbsp;
</Typography>
<Typography variant="body2">{value}</Typography>
</Grid>
));
LabelValue.displayName = 'LabelValue';
const ICON_SIZE = 16;
const CustomizationsDialog = ({
open,
onClose,
@@ -40,12 +57,23 @@ const CustomizationsDialog = ({
const [editItem, setEditItem] = useState<DeviceEntity>(selectedItem);
const [error, setError] = useState<boolean>(false);
const updateFormValue = updateValue(setEditItem);
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
);
const isWriteableNumber =
typeof editItem.v === 'number' &&
editItem.w &&
!(editItem.m & DeviceEntityMask.DV_READONLY);
const isWriteableNumber = useMemo(
() =>
typeof editItem.v === 'number' &&
editItem.w &&
!(editItem.m & DeviceEntityMask.DV_READONLY),
[editItem.v, editItem.w, editItem.m]
);
useEffect(() => {
if (open) {
@@ -54,63 +82,59 @@ const CustomizationsDialog = ({
}
}, [open, selectedItem]);
const handleClose = (_event, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
};
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
},
[onClose]
);
const save = () => {
const save = useCallback(() => {
if (
isWriteableNumber &&
editItem.mi &&
editItem.ma &&
editItem.mi > editItem?.ma
editItem.mi > editItem.ma
) {
setError(true);
} else {
onSave(editItem);
}
};
}, [isWriteableNumber, editItem, onSave]);
const updateDeviceEntity = (updatedItem: DeviceEntity) => {
setEditItem({ ...editItem, m: updatedItem.m });
};
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
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 (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>{LL.EDIT() + ' ' + LL.ENTITY()}</DialogTitle>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent dividers>
<Grid container>
<Typography variant="body2" color="warning.main">
{LL.ID_OF(LL.ENTITY())}:&nbsp;
</Typography>
<Typography variant="body2">{editItem.id}</Typography>
</Grid>
<Grid container direction="row">
<Typography variant="body2" color="warning.main">
{LL.DEFAULT(1) + ' ' + LL.ENTITY_NAME(1)}:&nbsp;
</Typography>
<Typography variant="body2">{editItem.n}</Typography>
</Grid>
<Grid container direction="row">
<Typography variant="body2" color="warning.main">
{LL.WRITEABLE()}:&nbsp;
</Typography>
<Typography variant="body2">
{editItem.w ? (
<DoneIcon color="success" sx={{ fontSize: 16 }} />
) : (
<CloseIcon color="error" sx={{ fontSize: 16 }} />
)}
</Typography>
</Grid>
<LabelValue label={LL.ID_OF(LL.ENTITY())} value={editItem.id} />
<LabelValue
label={`${LL.DEFAULT(1)} ${LL.ENTITY_NAME(1)}`}
value={editItem.n}
/>
<LabelValue label={LL.WRITEABLE()} value={writeableIcon} />
<Box mt={1} mb={2}>
<EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} />
</Box>
<Grid container spacing={2}>
<Grid>
<TextField
@@ -146,12 +170,14 @@ const CustomizationsDialog = ({
</>
)}
</Grid>
{error && (
<Typography variant="body2" color="error" mt={2}>
Error: Check min and max values
</Typography>
)}
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}

View File

@@ -1,4 +1,4 @@
import { useContext, useEffect, useState } from 'react';
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { IconContext } from 'react-icons/lib';
import { Link } from 'react-router';
import { toast } from 'react-toastify';
@@ -14,6 +14,7 @@ import {
IconButton,
ToggleButton,
ToggleButtonGroup,
Tooltip,
Typography
} from '@mui/material';
@@ -44,7 +45,7 @@ import {
} from './types';
import { deviceValueItemValidation } from './validators';
const Dashboard = () => {
const Dashboard = memo(() => {
const { LL } = useI18nContext();
const { me } = useContext(AuthenticatedContext);
@@ -76,35 +77,40 @@ const Dashboard = () => {
}
);
const deviceValueDialogSave = async (devicevalue: DeviceValue) => {
if (!selectedDashboardItem) {
return;
}
const id = selectedDashboardItem.parentNode.id; // this is the parent ID
await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v })
.then(() => {
toast.success(LL.WRITE_CMD_SENT());
})
.catch((error: Error) => {
toast.error(error.message);
})
.finally(() => {
setDeviceValueDialogOpen(false);
setSelectedDashboardItem(undefined);
});
};
const deviceValueDialogSave = useCallback(
async (devicevalue: DeviceValue) => {
if (!selectedDashboardItem) {
return;
}
const id = selectedDashboardItem.parentNode.id; // this is the parent ID
await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v })
.then(() => {
toast.success(LL.WRITE_CMD_SENT());
})
.catch((error: Error) => {
toast.error(error.message);
})
.finally(() => {
setDeviceValueDialogOpen(false);
setSelectedDashboardItem(undefined);
});
},
[selectedDashboardItem, sendDeviceValue, LL]
);
const dashboard_theme = useTheme({
Table: `
const dashboard_theme = useMemo(
() =>
useTheme({
Table: `
--data-table-library_grid-template-columns: minmax(80px, auto) 120px 32px;
`,
BaseRow: `
BaseRow: `
font-size: 14px;
.td {
height: 28px;
}
`,
Row: `
Row: `
cursor: pointer;
background-color: #1e1e1e;
&:nth-of-type(odd) .td {
@@ -114,7 +120,7 @@ const Dashboard = () => {
background-color: #177ac9;
},
`,
BaseCell: `
BaseCell: `
&:nth-of-type(2) {
text-align: right;
}
@@ -122,12 +128,14 @@ const Dashboard = () => {
text-align: right;
}
`
});
}),
[]
);
const tree = useTree(
{ nodes: data.nodes },
{ nodes: [...data.nodes] },
{
onChange: undefined // not used but needed
onChange: () => {} // not used but needed
},
{
treeIcon: {
@@ -156,65 +164,82 @@ const Dashboard = () => {
}
});
const nodeIds = useMemo(
() => data.nodes.map((item: DashboardItem) => item.id),
[data.nodes]
);
useEffect(() => {
showAll
? tree.fns.onAddAll(data.nodes.map((item: DashboardItem) => item.id)) // expand tree
? tree.fns.onAddAll(nodeIds) // expand tree
: tree.fns.onRemoveAll(); // collapse tree
}, [parentNodes]);
const showType = (n?: string, t?: number) => {
// if we have a name show it
if (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;
const showType = useCallback(
(n?: string, t?: number) => {
// if we have a name show it
if (n) {
return n;
}
}
return '';
};
const showName = (di: DashboardItem) => {
if (di.id < 100) {
// if its a device (parent node) and has entities
if (di.nodes?.length) {
return (
<span style={{ fontWeight: 'bold', fontSize: '14px' }}>
<DeviceIcon type_id={di.t ?? 0} />
&nbsp;&nbsp;{showType(di.n, di.t)}
<span style={{ color: 'lightblue' }}>&nbsp;({di.nodes?.length})</span>
</span>
);
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 (di.dv) {
return <span>{di.dv.id.slice(2)}</span>;
}
};
return '';
},
[LL]
);
const hasMask = (id: string, mask: number) =>
(parseInt(id.slice(0, 2), 16) & mask) === mask;
const showName = useCallback(
(di: DashboardItem) => {
if (di.id < 100) {
// if its a device (parent node) and has entities
if (di.nodes?.length) {
return (
<span style={{ fontSize: '15px' }}>
<DeviceIcon type_id={di.t ?? 0} />
&nbsp;&nbsp;{showType(di.n, di.t)}
<span style={{ color: 'lightblue' }}>&nbsp;({di.nodes?.length})</span>
</span>
);
}
}
if (di.dv) {
return <span>{di.dv.id.slice(2)}</span>;
}
return null;
},
[showType]
);
const editDashboardValue = (di: DashboardItem) => {
if (me.admin && di.dv?.c) {
setSelectedDashboardItem(di);
setDeviceValueDialogOpen(true);
}
};
const hasMask = useCallback(
(id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask,
[]
);
const editDashboardValue = useCallback(
(di: DashboardItem) => {
if (me.admin && di.dv?.c) {
setSelectedDashboardItem(di);
setDeviceValueDialogOpen(true);
}
},
[me.admin]
);
const handleShowAll = (
event: React.MouseEvent<HTMLElement>,
_event: React.MouseEvent<HTMLElement>,
toggle: boolean | null
) => {
if (toggle !== null) {
@@ -223,19 +248,22 @@ const Dashboard = () => {
}
};
const hasFavEntities = useMemo(
() => data.nodes.filter((item: DashboardItem) => item.id <= 90).length,
[data.nodes]
);
const renderContent = () => {
if (!data) {
return <FormLoader onRetry={fetchDashboard} errorMessage={error?.message} />;
return (
<FormLoader onRetry={fetchDashboard} errorMessage={error?.message || ''} />
);
}
const hasFavEntities = data.nodes.filter(
(item: DashboardItem) => item.id <= 90
).length;
return (
<>
{!data.connected && (
<MessageBox mb={2} level="error" message={LL.EMS_BUS_WARNING()} />
<MessageBox level="error" message={LL.EMS_BUS_WARNING()} />
)}
{data.connected && data.nodes.length > 0 && !hasFavEntities && (
@@ -255,105 +283,121 @@ const Dashboard = () => {
</MessageBox>
)}
{data.nodes.length > 0 && (
<>
<ToggleButtonGroup
color="primary"
size="small"
value={showAll}
exclusive
onChange={handleShowAll}
>
<ButtonTooltip title={LL.ALLVALUES()} arrow>
<ToggleButton value={true}>
<UnfoldMoreIcon sx={{ fontSize: 18 }} />
</ToggleButton>
</ButtonTooltip>
<ButtonTooltip title={LL.COMPACT()} arrow>
<ToggleButton value={false}>
<UnfoldLessIcon sx={{ fontSize: 18 }} />
</ToggleButton>
</ButtonTooltip>
</ToggleButtonGroup>
<ButtonTooltip title={LL.DASHBOARD_1()} arrow>
<HelpOutlineIcon color="primary" sx={{ ml: 1, fontSize: 20 }} />
<Box
display="flex"
justifyContent="flex-end"
flexWrap="nowrap"
whiteSpace="nowrap"
>
<ToggleButtonGroup
size="small"
color="primary"
value={showAll}
exclusive
onChange={handleShowAll}
>
<ButtonTooltip title={LL.ALLVALUES()}>
<ToggleButton value={true}>
<UnfoldMoreIcon sx={{ fontSize: 18 }} />
</ToggleButton>
</ButtonTooltip>
<ButtonTooltip title={LL.COMPACT()}>
<ToggleButton value={false}>
<UnfoldLessIcon sx={{ fontSize: 18 }} />
</ToggleButton>
</ButtonTooltip>
</ToggleButtonGroup>
</Box>
<Box
padding={1}
justifyContent="center"
flexDirection="column"
sx={{
borderRadius: 1,
border: '1px solid grey'
{data.nodes.length > 0 ? (
<Box mt={1} justifyContent="center" flexDirection="column">
<IconContext.Provider
value={{
color: 'lightblue',
size: '18',
style: { verticalAlign: 'middle' }
}}
>
<IconContext.Provider
value={{
color: 'lightblue',
size: '18',
style: { verticalAlign: 'middle' }
}}
<Table
data={{ nodes: data.nodes }}
theme={dashboard_theme}
layout={{ custom: true }}
tree={tree}
>
<Table
data={{ nodes: data.nodes }}
theme={dashboard_theme}
layout={{ custom: true }}
tree={tree}
>
{(tableList: DashboardItem[]) => (
<Body>
{tableList.map((di: DashboardItem) => (
<Row
key={di.id}
item={di}
onClick={() => editDashboardValue(di)}
>
{di.id > 99 ? (
<>
<Cell>{showName(di)}</Cell>
<Cell>
<ButtonTooltip
title={formatValue(LL, di.dv?.v, di.dv?.u)}
>
<span>{formatValue(LL, di.dv?.v, di.dv?.u)}</span>
</ButtonTooltip>
</Cell>
{(tableList: DashboardItem[]) => (
<Body>
{tableList.map((di: DashboardItem) => (
<Row
key={di.id}
item={di}
onClick={() => editDashboardValue(di)}
>
{di.id > 99 ? (
<>
<Cell>{showName(di)}</Cell>
<Cell>
<ButtonTooltip
title={formatValue(LL, di.dv?.v, di.dv?.u)}
>
<span>{formatValue(LL, di.dv?.v, di.dv?.u)}</span>
</ButtonTooltip>
</Cell>
<Cell>
{me.admin &&
di.dv?.c &&
!hasMask(
di.dv.id,
DeviceEntityMask.DV_READONLY
) && (
<IconButton
size="small"
onClick={() => editDashboardValue(di)}
>
<EditIcon
color="primary"
sx={{ fontSize: 16 }}
/>
</IconButton>
)}
</Cell>
</>
) : (
<>
<CellTree item={di}>{showName(di)}</CellTree>
<Cell />
<Cell />
</>
)}
</Row>
))}
</Body>
)}
</Table>
</IconContext.Provider>
</Box>
</>
<Cell>
{me.admin &&
di.dv?.c &&
!hasMask(di.dv.id, DeviceEntityMask.DV_READONLY) && (
<IconButton
size="small"
aria-label={
LL.CHANGE_VALUE() + ' ' + LL.VALUE(0)
}
onClick={() => editDashboardValue(di)}
>
<EditIcon
color="primary"
sx={{ fontSize: 16 }}
/>
</IconButton>
)}
</Cell>
</>
) : (
<>
<CellTree item={di}>{showName(di)}</CellTree>
<Cell />
<Cell />
</>
)}
</Row>
))}
</Body>
)}
</Table>
</IconContext.Provider>
</Box>
) : (
<Box
display="flex"
// justifyContent="flex-end"
// flexWrap="nowrap"
// whiteSpace="nowrap"
>
<Typography mt={1} color="warning.main" variant="body1">
no data
</Typography>
<Tooltip title={LL.DASHBOARD_1()}>
<HelpOutlineIcon
sx={{
ml: 1,
mt: 1,
fontSize: 20,
verticalAlign: 'middle'
}}
color="primary"
/>
</Tooltip>
</Box>
)}
</>
);
@@ -375,6 +419,6 @@ const Dashboard = () => {
)}
</SectionContent>
);
};
});
export default Dashboard;

View File

@@ -1,13 +1,14 @@
import { memo } from 'react';
import { AiOutlineAlert, AiOutlineControl, AiOutlineGateway } from 'react-icons/ai';
import { CgSmartHomeBoiler } from 'react-icons/cg';
import { FaSolarPanel } from 'react-icons/fa';
import { GiHeatHaze, GiTap } from 'react-icons/gi';
import { MdPlaylistAdd } from 'react-icons/md';
import { MdMoreTime } from 'react-icons/md';
import {
MdMoreTime,
MdOutlineDevices,
MdOutlinePool,
MdOutlineSensors,
MdPlaylistAdd,
MdThermostatAuto
} from 'react-icons/md';
import { PiFan, PiGauge } from 'react-icons/pi';
@@ -18,9 +19,10 @@ import type { SvgIconProps } from '@mui/material';
import { DeviceType } from './types';
const deviceIconLookup: {
[key in DeviceType]: React.ComponentType<SvgIconProps> | undefined;
} = {
const deviceIconLookup: Record<
DeviceType,
React.ComponentType<SvgIconProps> | null
> = {
[DeviceType.TEMPERATURESENSOR]: TiThermometer,
[DeviceType.ANALOGSENSOR]: PiGauge,
[DeviceType.BOILER]: CgSmartHomeBoiler,
@@ -39,15 +41,19 @@ const deviceIconLookup: {
[DeviceType.POOL]: MdOutlinePool,
[DeviceType.CUSTOM]: MdPlaylistAdd,
[DeviceType.UNKNOWN]: MdOutlineSensors,
[DeviceType.SYSTEM]: undefined,
[DeviceType.SYSTEM]: null,
[DeviceType.SCHEDULER]: MdMoreTime,
[DeviceType.GENERIC]: MdOutlineSensors,
[DeviceType.VENTILATION]: PiFan
};
const DeviceIcon = ({ type_id }: { type_id: DeviceType }) => {
interface DeviceIconProps {
type_id: DeviceType;
}
const DeviceIcon = memo(({ type_id }: DeviceIconProps) => {
const Icon = deviceIconLookup[type_id];
return Icon ? <Icon /> : null;
};
});
export default DeviceIcon;

View File

@@ -1,8 +1,10 @@
import {
memo,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useState
} from 'react';
import { IconContext } from 'react-icons';
@@ -31,7 +33,7 @@ import {
DialogActions,
DialogContent,
DialogTitle,
Grid2 as Grid,
Grid,
IconButton,
InputAdornment,
List,
@@ -75,7 +77,7 @@ import { DeviceEntityMask, DeviceType, DeviceValueUOM_s } from './types';
import type { Device, DeviceValue } from './types';
import { deviceValueItemValidation } from './validators';
const Devices = () => {
const Devices = memo(() => {
const { LL } = useI18nContext();
const { me } = useContext(AuthenticatedContext);
@@ -91,7 +93,7 @@ const Devices = () => {
useLayoutTitle(LL.DEVICES());
const { data: coreData, send: sendCoreData } = useRequest(() => readCoreData(), {
const { data: coreData, send: sendCoreData } = useRequest(readCoreData, {
initialData: {
connected: true,
devices: []
@@ -116,36 +118,36 @@ const Devices = () => {
);
useLayoutEffect(() => {
function updateSize() {
setSize([window.innerWidth, window.innerHeight]);
}
let raf = 0;
const updateSize = () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
setSize([window.innerWidth, window.innerHeight]);
});
};
window.addEventListener('resize', updateSize);
updateSize();
return () => window.removeEventListener('resize', updateSize);
return () => {
window.removeEventListener('resize', updateSize);
cancelAnimationFrame(raf);
};
}, []);
const leftOffset = () => {
const leftOffset = useCallback(() => {
const devicesWindow = document.getElementById('devices-window');
if (!devicesWindow) {
return 0;
}
const clientRect = devicesWindow.getBoundingClientRect();
const left = clientRect.left;
const right = clientRect.right;
if (!left || !right) {
return 0;
}
if (!devicesWindow) return 0;
const { left, right } = devicesWindow.getBoundingClientRect();
if (!left || !right) return 0;
return left + (right - left < 400 ? 0 : 200);
};
}, []);
const common_theme = useTheme({
BaseRow: `
const common_theme = useMemo(
() =>
useTheme({
BaseRow: `
font-size: 14px;
`,
HeaderRow: `
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
@@ -153,7 +155,7 @@ const Devices = () => {
border-bottom: 1px solid #565656;
}
`,
Row: `
Row: `
cursor: pointer;
background-color: #1E1E1E;
.td {
@@ -163,30 +165,47 @@ const Devices = () => {
background-color: #177ac9;
}
`
});
}),
[]
);
const device_theme = useTheme([
common_theme,
{
Table: `
const device_theme = useMemo(
() =>
useTheme([
common_theme,
{
BaseRow: `
font-size: 15px;
.td {
height: 28px;
}
`,
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px;
`,
HeaderRow: `
HeaderRow: `
.th {
padding: 8px;
`,
Row: `
font-weight: bold;
Row: `
&:nth-of-type(odd) .td {
background-color: #303030;
},
&:hover .td {
background-color: #177ac9;
},
`
}
]);
}
]),
[common_theme]
);
const data_theme = useTheme([
common_theme,
{
Table: `
const data_theme = useMemo(
() =>
useTheme([
common_theme,
{
Table: `
--data-table-library_grid-template-columns: minmax(200px, auto) minmax(150px, auto) 40px;
height: auto;
max-height: 100%;
@@ -195,12 +214,12 @@ const Devices = () => {
display:none;
}
`,
BaseRow: `
BaseRow: `
.td {
height: 32px;
}
`,
BaseCell: `
BaseCell: `
&:nth-of-type(1) {
border-left: 1px solid #177ac9;
},
@@ -211,12 +230,12 @@ const Devices = () => {
border-right: 1px solid #177ac9;
}
`,
HeaderRow: `
HeaderRow: `
.th {
border-top: 1px solid #565656;
}
`,
Row: `
Row: `
&:nth-of-type(odd) .td {
background-color: #303030;
},
@@ -224,8 +243,10 @@ const Devices = () => {
background-color: #177ac9;
}
`
}
]);
}
]),
[common_theme]
);
const getSortIcon = (state: State, sortKey: unknown) => {
if (state.sortKey === sortKey && state.reverse) {
@@ -238,7 +259,7 @@ const Devices = () => {
};
const dv_sort = useSort(
{ nodes: deviceData.nodes },
{ nodes: [...deviceData.nodes] },
{},
{
sortIcon: {
@@ -268,7 +289,7 @@ const Devices = () => {
}
const device_select = useRowSelect(
{ nodes: coreData.devices },
{ nodes: [...coreData.devices] },
{
onChange: onSelectChange
}
@@ -324,18 +345,23 @@ const Devices = () => {
return sc;
};
const hasMask = (id: string, mask: number) =>
(parseInt(id.slice(0, 2), 16) & mask) === mask;
const hasMask = useCallback(
(id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask,
[]
);
const handleDownloadCsv = () => {
const deviceIndex = coreData.devices.findIndex(
(d) => d.id === device_select.state.id
(d: Device) => d.id === device_select.state.id
);
if (deviceIndex === -1) {
return;
}
const filename =
coreData.devices[deviceIndex].tn + '_' + coreData.devices[deviceIndex].n;
const selectedDevice = coreData.devices[deviceIndex];
if (!selectedDevice) {
return;
}
const filename = selectedDevice.tn + '_' + selectedDevice.n;
const columns = [
{
@@ -350,7 +376,7 @@ const Devices = () => {
{
accessor: (dv: DeviceValue) =>
dv.u !== undefined && DeviceValueUOM_s[dv.u]
? DeviceValueUOM_s[dv.u].replace(/[^a-zA-Z0-9]/g, '')
? DeviceValueUOM_s[dv.u]?.replace(/[^a-zA-Z0-9]/g, '')
: '',
name: 'UoM'
},
@@ -373,7 +399,9 @@ const Devices = () => {
];
const data = onlyFav
? deviceData.nodes.filter((dv) => hasMask(dv.id, DeviceEntityMask.DV_FAVORITE))
? deviceData.nodes.filter((dv: DeviceValue) =>
hasMask(dv.id, DeviceEntityMask.DV_FAVORITE)
)
: deviceData.nodes;
const csvData = data.reduce(
@@ -433,10 +461,14 @@ const Devices = () => {
const renderDeviceDetails = () => {
if (showDeviceInfo) {
const deviceIndex = coreData.devices.findIndex(
(d) => d.id === device_select.state.id
(d: Device) => d.id === device_select.state.id
);
if (deviceIndex === -1) {
return;
return null;
}
const deviceDetails = coreData.devices[deviceIndex];
if (!deviceDetails) {
return null;
}
return (
@@ -449,47 +481,35 @@ const Devices = () => {
<DialogContent dividers>
<List dense={true}>
<ListItem>
<ListItemText
primary={LL.TYPE(0)}
secondary={coreData.devices[deviceIndex].tn}
/>
<ListItemText primary={LL.TYPE(0)} secondary={deviceDetails.tn} />
</ListItem>
<ListItem>
<ListItemText
primary={LL.NAME(0)}
secondary={coreData.devices[deviceIndex].n}
/>
<ListItemText primary={LL.NAME(0)} secondary={deviceDetails.n} />
</ListItem>
{coreData.devices[deviceIndex].t !== DeviceType.CUSTOM && (
{deviceDetails.t !== DeviceType.CUSTOM && (
<>
<ListItem>
<ListItemText
primary={LL.BRAND()}
secondary={coreData.devices[deviceIndex].b}
/>
<ListItemText primary={LL.BRAND()} secondary={deviceDetails.b} />
</ListItem>
<ListItem>
<ListItemText
primary={LL.ID_OF(LL.DEVICE())}
secondary={
'0x' +
(
'00' +
coreData.devices[deviceIndex].d.toString(16).toUpperCase()
).slice(-2)
('00' + deviceDetails.d.toString(16).toUpperCase()).slice(-2)
}
/>
</ListItem>
<ListItem>
<ListItemText
primary={LL.ID_OF(LL.PRODUCT())}
secondary={coreData.devices[deviceIndex].p}
secondary={deviceDetails.p}
/>
</ListItem>
<ListItem>
<ListItemText
primary={LL.VERSION()}
secondary={coreData.devices[deviceIndex].v}
secondary={deviceDetails.v}
/>
</ListItem>
</>
@@ -508,59 +528,60 @@ const Devices = () => {
</Dialog>
);
}
return null;
};
const renderCoreData = () => (
<>
<IconContext.Provider
value={{
color: 'lightblue',
size: '18',
style: { verticalAlign: 'middle' }
}}
>
{!coreData.connected && (
<MessageBox my={2} level="error" message={LL.EMS_BUS_WARNING()} />
)}
{coreData.connected && (
<Table
data={{ nodes: coreData.devices }}
select={device_select}
theme={device_theme}
layout={{ custom: true }}
{!coreData.connected ? (
<MessageBox level="error" message={LL.EMS_BUS_WARNING()} />
) : (
<Box justifyContent="center" flexDirection="column">
<IconContext.Provider
value={{
color: 'lightblue',
size: '18',
style: { verticalAlign: 'middle' }
}}
>
{(tableList: Device[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell resize>{LL.DESCRIPTION()}</HeaderCell>
<HeaderCell stiff>{LL.TYPE(0)}</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.length === 0 && (
<CircularProgress sx={{ margin: 1 }} size={18} />
)}
{tableList.map((device: Device) => (
<Row key={device.id} item={device}>
<Cell>
<DeviceIcon type_id={device.t} />
&nbsp;&nbsp;
{device.n}
<span style={{ color: 'lightblue' }}>
&nbsp;&nbsp;({device.e})
</span>
</Cell>
<Cell stiff>{device.tn}</Cell>
</Row>
))}
</Body>
</>
)}
</Table>
)}
</IconContext.Provider>
<Table
data={{ nodes: [...coreData.devices] }}
select={device_select}
theme={device_theme}
layout={{ custom: true }}
>
{(tableList: Device[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell resize>{LL.DESCRIPTION()}</HeaderCell>
<HeaderCell stiff>{LL.TYPE(0)}</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.length === 0 && (
<CircularProgress sx={{ margin: 1 }} size={18} />
)}
{tableList.map((device: Device) => (
<Row key={device.id} item={device}>
<Cell>
<DeviceIcon type_id={device.t} />
&nbsp;&nbsp;
{device.n}
<span style={{ color: 'lightblue' }}>
&nbsp;&nbsp;({device.e})
</span>
</Cell>
<Cell stiff>{device.tn}</Cell>
</Row>
))}
</Body>
</>
)}
</Table>
</IconContext.Provider>
</Box>
)}
</>
);
@@ -576,64 +597,77 @@ const Devices = () => {
return;
}
const showDeviceValue = (dv: DeviceValue) => {
const showDeviceValue = useCallback((dv: DeviceValue) => {
setSelectedDeviceValue(dv);
setDeviceValueDialogOpen(true);
};
}, []);
const renderNameCell = (dv: DeviceValue) => (
<>
{dv.id.slice(2)}&nbsp;
{hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && (
<StarIcon color="primary" sx={{ fontSize: 12 }} />
)}
{hasMask(dv.id, DeviceEntityMask.DV_READONLY) && (
<EditOffOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
)}
{hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && (
<CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
)}
</>
const renderNameCell = useCallback(
(dv: DeviceValue) => (
<>
{dv.id.slice(2)}&nbsp;
{hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && (
<StarIcon color="primary" sx={{ fontSize: 12 }} />
)}
{hasMask(dv.id, DeviceEntityMask.DV_READONLY) && (
<EditOffOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
)}
{hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && (
<CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
)}
</>
),
[hasMask]
);
const shown_data = onlyFav
? deviceData.nodes.filter(
(dv) =>
const shown_data = useMemo(() => {
if (onlyFav) {
return deviceData.nodes.filter(
(dv: DeviceValue) =>
hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) &&
dv.id.slice(2).includes(search)
)
: deviceData.nodes.filter((dv) => dv.id.slice(2).includes(search));
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
);
}
return deviceData.nodes.filter((dv: DeviceValue) =>
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
);
}, [deviceData.nodes, onlyFav, search]);
const deviceIndex = coreData.devices.findIndex(
(d) => d.id === device_select.state.id
(d: Device) => d.id === device_select.state.id
);
if (deviceIndex === -1) {
return;
}
const deviceInfo = coreData.devices[deviceIndex];
if (!deviceInfo) {
return;
}
const [, height] = size;
return (
<Box
sx={{
backgroundColor: 'black',
position: 'absolute',
left: () => leftOffset(),
left: leftOffset,
right: 0,
bottom: 0,
top: 64,
zIndex: 'modal',
maxHeight: () => size[1] - 126,
maxHeight: () => (height || 0) - 126,
border: '1px solid #177ac9'
}}
>
<Box sx={{ p: 1 }}>
<Grid container justifyContent="space-between">
<Typography noWrap variant="subtitle1" color="warning.main">
{coreData.devices[deviceIndex].n}&nbsp;(
{coreData.devices[deviceIndex].tn})
{deviceInfo.n}&nbsp;(
{deviceInfo.tn})
</Typography>
<Grid justifyContent="flex-end">
<ButtonTooltip title={LL.CLOSE()}>
<IconButton onClick={resetDeviceSelect}>
<IconButton onClick={resetDeviceSelect} aria-label={LL.CLOSE()}>
<HighlightOffIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
</ButtonTooltip>
@@ -645,6 +679,7 @@ const Devices = () => {
variant="outlined"
sx={{ width: '22ch' }}
placeholder={LL.SEARCH()}
aria-label={LL.SEARCH()}
onChange={(event) => {
setSearch(event.target.value);
}}
@@ -659,19 +694,22 @@ const Devices = () => {
}}
/>
<ButtonTooltip title={LL.DEVICE_DETAILS()}>
<IconButton onClick={() => setShowDeviceInfo(true)}>
<IconButton
onClick={() => setShowDeviceInfo(true)}
aria-label={LL.DEVICE_DETAILS()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
</ButtonTooltip>
{me.admin && (
<ButtonTooltip title={LL.CUSTOMIZATIONS()}>
<IconButton onClick={customize}>
<IconButton onClick={customize} aria-label={LL.CUSTOMIZATIONS()}>
<ConstructionIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
</ButtonTooltip>
)}
<ButtonTooltip title={LL.EXPORT()}>
<IconButton onClick={handleDownloadCsv}>
<IconButton onClick={handleDownloadCsv} aria-label={LL.EXPORT()}>
<DownloadIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
</ButtonTooltip>
@@ -699,14 +737,14 @@ const Devices = () => {
' ' +
shown_data.length +
'/' +
coreData.devices[deviceIndex].e +
deviceInfo.e +
' ' +
LL.ENTITIES(shown_data.length)}
</span>
</Box>
<Table
data={{ nodes: shown_data }}
data={{ nodes: Array.from(shown_data) }}
theme={data_theme}
sort={dv_sort}
layout={{ custom: true, fixedHeader: true }}
@@ -790,6 +828,6 @@ const Devices = () => {
)}
</SectionContent>
);
};
});
export default Devices;

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning';
@@ -11,7 +11,7 @@ import {
DialogContent,
DialogTitle,
FormHelperText,
Grid2 as Grid,
Grid,
InputAdornment,
MenuItem,
TextField,
@@ -52,7 +52,7 @@ const DevicesDialog = ({
const [editItem, setEditItem] = useState<DeviceValue>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValue(setEditItem);
const updateFormValue = useMemo(() => updateValue(setEditItem), [setEditItem]);
useEffect(() => {
if (open) {
@@ -61,11 +61,7 @@ const DevicesDialog = ({
}
}, [open, selectedItem]);
const close = () => {
onClose();
};
const save = async () => {
const save = useCallback(async () => {
try {
setFieldErrors(undefined);
await validate(validator, editItem);
@@ -73,46 +69,66 @@ const DevicesDialog = ({
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
};
}, [validator, editItem, onSave]);
const setUom = (uom?: DeviceValueUOM) => {
if (uom === undefined) {
return;
}
switch (uom) {
case DeviceValueUOM.HOURS:
return LL.HOURS();
case DeviceValueUOM.MINUTES:
return LL.MINUTES();
case DeviceValueUOM.SECONDS:
return LL.SECONDS();
default:
return DeviceValueUOM_s[uom];
}
};
const setUom = useCallback(
(uom?: DeviceValueUOM) => {
if (uom === undefined) {
return;
}
switch (uom) {
case DeviceValueUOM.HOURS:
return LL.HOURS();
case DeviceValueUOM.MINUTES:
return LL.MINUTES();
case DeviceValueUOM.SECONDS:
return LL.SECONDS();
default:
return DeviceValueUOM_s[uom];
}
},
[LL]
);
const showHelperText = (dv: DeviceValue) =>
dv.h ? (
dv.h
) : dv.l ? (
dv.l.join(' | ')
) : dv.m !== undefined && dv.x !== undefined ? (
<>
{dv.m}&nbsp;&rarr;&nbsp;{dv.x}
</>
) : undefined;
const showHelperText = useCallback((dv: DeviceValue) => {
if (dv.h) return dv.h;
if (dv.l) return dv.l.join(' | ');
if (dv.m !== undefined && dv.x !== undefined) {
return (
<>
{dv.m}&nbsp;&rarr;&nbsp;{dv.x}
</>
);
}
return undefined;
}, []);
const isCommand = useMemo(
() => selectedItem.v === '' && selectedItem.c,
[selectedItem.v, selectedItem.c]
);
const dialogTitle = useMemo(() => {
if (isCommand) return LL.RUN_COMMAND();
return writeable ? LL.CHANGE_VALUE() : LL.VALUE(0);
}, [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);
return (
<Dialog sx={dialogStyle} open={open} onClose={close}>
<DialogTitle>
{selectedItem.v === '' && selectedItem.c
? LL.RUN_COMMAND()
: writeable
? LL.CHANGE_VALUE()
: LL.VALUE(0)}
</DialogTitle>
<Dialog sx={dialogStyle} open={open} onClose={onClose}>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent dividers>
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
<Box color="warning.main" mb={2}>
<Typography variant="body2">{editItem.id.slice(2)}</Typography>
</Box>
<Grid container>
@@ -120,8 +136,8 @@ const DevicesDialog = ({
{editItem.l ? (
<TextField
name="v"
label={LL.VALUE(0)}
value={editItem.v}
aria-label={valueLabel}
disabled={!writeable}
sx={{ width: '30ch' }}
select
@@ -135,9 +151,9 @@ const DevicesDialog = ({
</TextField>
) : editItem.s || editItem.u !== DeviceValueUOM.NONE ? (
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="v"
label={LL.VALUE(0)}
label={valueLabel}
value={numberValue(Math.round((editItem.v as number) * 10) / 10)}
autoFocus
disabled={!writeable}
@@ -159,9 +175,9 @@ const DevicesDialog = ({
/>
) : (
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="v"
label={LL.VALUE(0)}
label={valueLabel}
value={editItem.v}
disabled={!writeable}
sx={{ width: '30ch' }}
@@ -170,9 +186,9 @@ const DevicesDialog = ({
/>
)}
</Grid>
{writeable && (
{writeable && helperText && (
<Grid>
<FormHelperText>{showHelperText(editItem)}</FormHelperText>
<FormHelperText>{helperText}</FormHelperText>
</Grid>
)}
</Grid>
@@ -191,7 +207,7 @@ const DevicesDialog = ({
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={close}
onClick={onClose}
color="secondary"
>
{LL.CANCEL()}
@@ -202,7 +218,7 @@ const DevicesDialog = ({
onClick={save}
color="primary"
>
{selectedItem.v === '' && selectedItem.c ? LL.EXECUTE() : LL.UPDATE()}
{buttonLabel}
</Button>
{progress && (
<CircularProgress
@@ -217,7 +233,7 @@ const DevicesDialog = ({
)}
</Box>
) : (
<Button variant="outlined" onClick={close} color="secondary">
<Button variant="outlined" onClick={onClose} color="secondary">
{LL.CLOSE()}
</Button>
)}

View File

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

View File

@@ -1,4 +1,5 @@
import { useContext, useState } from 'react';
import { memo, useCallback, useContext, useMemo, useState } from 'react';
import type { ReactElement } from 'react';
import { toast } from 'react-toastify';
import CommentIcon from '@mui/icons-material/CommentTwoTone';
@@ -19,6 +20,7 @@ import {
Stack,
Typography
} from '@mui/material';
import type { SxProps, Theme } from '@mui/material/styles';
import { useRequest } from 'alova/client';
import { SectionContent, useLayoutTitle } from 'components';
@@ -29,25 +31,61 @@ import { saveFile } from 'utils';
import { API, callAction } from '../../api/app';
import type { APIcall } from './types';
const Help = () => {
interface HelpLink {
href: string;
icon: ReactElement;
label: () => string;
}
interface CustomSupport {
img_url: string | null;
html: string | null;
}
const DEFAULT_IMAGE_URL = 'https://emsesp.org/_media/images/installer.jpeg';
const SUPPORT_BOX_STYLES: SxProps<Theme> = {
borderRadius: 3,
border: '1px solid lightblue',
justifyContent: 'space-evenly',
alignItems: 'center'
};
const IMAGE_STYLES: SxProps<Theme> = {
maxHeight: { xs: 100, md: 250 }
};
const AVATAR_STYLES: SxProps<Theme> = {
bgcolor: '#72caf9'
};
const HelpComponent = () => {
const { LL } = useI18nContext();
useLayoutTitle(LL.HELP());
const { me } = useContext(AuthenticatedContext);
const [customSupportIMG, setCustomSupportIMG] = useState<string | null>(null);
const [customSupportHTML, setCustomSupportHTML] = useState<string | null>(null);
const [notFound, setNotFound] = useState<boolean>(false);
const [customSupport, setCustomSupport] = useState<CustomSupport>({
img_url: null,
html: null
});
const [imgError, setImgError] = useState<boolean>(false);
useRequest(() => callAction({ action: 'getCustomSupport' })).onSuccess((event) => {
if (event && event.data && Object.keys(event.data).length !== 0) {
const data = event.data.Support;
if (data.img_url) {
setCustomSupportIMG(data.img_url);
}
if (data.html) {
setCustomSupportHTML(data.html.join('<br/>'));
}
// Memoize the request method to prevent re-creation on every render
const getCustomSupportMethod = useMemo(
() => callAction({ action: 'getCustomSupport' }),
[]
);
useRequest(getCustomSupportMethod).onSuccess((event) => {
if (event?.data && Object.keys(event.data).length !== 0) {
const { Support } = event.data as {
Support: { img_url?: string; html?: string[] };
};
setCustomSupport({
img_url: Support.img_url || null,
html: Support.html?.join('<br/>') || null
});
}
});
@@ -59,93 +97,91 @@ const Help = () => {
toast.info(LL.DOWNLOAD_SUCCESSFUL());
})
.onError((error) => {
toast.error(error.message);
toast.error(String(error.error?.message || 'An error occurred'));
});
// Optimize API call memoization
const apiCall = useMemo(() => ({ device: 'system', cmd: 'info', id: 0 }), []);
const handleDownloadSystemInfo = useCallback(() => {
void sendAPI(apiCall);
}, [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/3J3GgnzpyT',
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 (
<SectionContent>
{customSupportHTML && (
{customSupport.html && (
<Stack
padding={1}
mb={2}
direction="row"
divider={<Divider orientation="vertical" flexItem />}
sx={{
borderRadius: 3,
border: '2px solid grey',
justifyContent: 'space-evenly',
alignItems: 'center'
}}
sx={SUPPORT_BOX_STYLES}
>
<Typography variant="subtitle1">
<div dangerouslySetInnerHTML={{ __html: customSupportHTML }} />
<div dangerouslySetInnerHTML={{ __html: customSupport.html }} />
</Typography>
<Box
component="img"
referrerPolicy="no-referrer"
sx={{
maxHeight: { xs: 100, md: 250 }
}}
onError={() => setNotFound(true)}
src={
notFound
? ''
: customSupportIMG ||
'https://docs.emsesp.org/_media/images/installer.jpeg'
}
sx={IMAGE_STYLES}
onError={handleImageError}
src={imageSrc}
/>
</Stack>
)}
{me.admin && (
<List sx={{ borderRadius: 3, border: '2px solid grey' }}>
<ListItem>
<ListItemButton
component="a"
target="_blank"
rel="noreferrer"
href="https://docs.emsesp.org"
>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#72caf9' }}>
<MenuBookIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.HELP_INFORMATION_1()} />
</ListItemButton>
</ListItem>
<ListItem>
<ListItemButton
component="a"
target="_blank"
rel="noreferrer"
href="https://discord.gg/3J3GgnzpyT"
>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#72caf9' }}>
<CommentIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.HELP_INFORMATION_2()} />
</ListItemButton>
</ListItem>
<ListItem>
<ListItemButton
component="a"
target="_blank"
rel="noreferrer"
href="https://github.com/emsesp/EMS-ESP32/issues/new/choose"
>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#72caf9' }}>
<GitHubIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.HELP_INFORMATION_3()} />
</ListItemButton>
</ListItem>
{isAdmin && (
<List>
{helpLinks.map(({ href, icon, label }) => (
<ListItem key={href}>
<ListItemButton
component="a"
target="_blank"
rel="noreferrer"
href={href}
>
<ListItemAvatar>
<Avatar sx={AVATAR_STYLES}>{icon}</Avatar>
</ListItemAvatar>
<ListItemText primary={label()} />
</ListItemButton>
</ListItem>
))}
</List>
)}
@@ -157,7 +193,7 @@ const Help = () => {
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={() => sendAPI({ device: 'system', cmd: 'info', id: 0 })}
onClick={handleDownloadSystemInfo}
>
{LL.SUPPORT_INFORMATION(0)}
</Button>
@@ -173,11 +209,14 @@ const Help = () => {
href="https://emsesp.org"
color="primary"
>
{'emsesp.org'}
emsesp.org
</Link>
</Typography>
</SectionContent>
);
};
// Memoize the component to prevent unnecessary re-renders
const Help = memo(HelpComponent);
export default Help;

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import { useBlocker } from 'react-router';
import { toast } from 'react-toastify';
@@ -31,6 +31,19 @@ import { readModules, writeModules } from '../../api/app';
import ModulesDialog from './ModulesDialog';
import type { ModuleItem } from './types';
const PENDING_COLOR = 'red';
const ACTIVATED_COLOR = '#00FF7F';
const hasModulesChanged = (mi: ModuleItem): boolean =>
mi.enabled !== mi.o_enabled || mi.license !== mi.o_license;
const ColorStatus = memo(({ status }: { status: number }) => {
if (status === 1) {
return <div style={{ color: PENDING_COLOR }}>Pending Activation</div>;
}
return <div style={{ color: ACTIVATED_COLOR }}>Activated</div>;
});
const Modules = () => {
const { LL } = useI18nContext();
const [numChanges, setNumChanges] = useState<number>(0);
@@ -56,105 +69,111 @@ const Modules = () => {
}
);
const modules_theme = useTheme({
Table: `
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
`,
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(1) {
text-align: center;
}
`,
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
.th {
border-bottom: 1px solid #565656;
height: 36px;
}
`,
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
.td {
border-top: 1px solid #565656;
border-bottom: 1px solid #565656;
}
&:hover .td {
border-top: 1px solid #177ac9;
border-bottom: 1px solid #177ac9;
}
&:nth-of-type(odd) .td {
background-color: #303030;
}
`
});
const modules_theme = useTheme(
useMemo(
() => ({
Table: `
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
`,
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(1) {
text-align: center;
}
`,
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
.th {
border-bottom: 1px solid #565656;
height: 36px;
}
`,
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
.td {
border-top: 1px solid #565656;
border-bottom: 1px solid #565656;
}
&:hover .td {
border-top: 1px solid #177ac9;
border-bottom: 1px solid #177ac9;
}
&:nth-of-type(odd) .td {
background-color: #303030;
}
`
}),
[]
)
);
const onDialogClose = () => {
const onDialogClose = useCallback(() => {
setDialogOpen(false);
};
}, []);
const onDialogSave = (updatedItem: ModuleItem) => {
setDialogOpen(false);
updateModuleItem(updatedItem);
};
const updateModuleItem = useCallback((updatedItem: ModuleItem) => {
void updateState(readModules(), (data: ModuleItem[]) => {
const new_data = data.map((mi) =>
mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi
);
setNumChanges(new_data.filter(hasModulesChanged).length);
return new_data;
});
}, []);
const onDialogSave = useCallback(
(updatedItem: ModuleItem) => {
setDialogOpen(false);
updateModuleItem(updatedItem);
},
[updateModuleItem]
);
const editModuleItem = useCallback((mi: ModuleItem) => {
setSelectedModuleItem(mi);
setDialogOpen(true);
}, []);
const onCancel = async () => {
const onCancel = useCallback(async () => {
await fetchModules().then(() => {
setNumChanges(0);
});
};
}, [fetchModules]);
function hasModulesChanged(mi: ModuleItem) {
return mi.enabled !== mi.o_enabled || mi.license !== mi.o_license;
}
const updateModuleItem = (updatedItem: ModuleItem) => {
void updateState(readModules(), (data: ModuleItem[]) => {
const new_data = data.map((mi) =>
mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi
const saveModules = useCallback(async () => {
try {
await Promise.all(
modules.map((condensed_mi: ModuleItem) =>
updateModules({
key: condensed_mi.key,
enabled: condensed_mi.enabled,
license: condensed_mi.license
})
)
);
setNumChanges(new_data.filter((mi) => hasModulesChanged(mi)).length);
return new_data;
});
};
toast.success(LL.MODULES_UPDATED());
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
await fetchModules();
setNumChanges(0);
}
}, [modules, updateModules, LL, fetchModules]);
const saveModules = async () => {
await updateModules({
modules: modules.map((condensed_mi) => ({
key: condensed_mi.key,
enabled: condensed_mi.enabled,
license: condensed_mi.license
}))
})
.then(() => {
toast.success(LL.MODULES_UPDATED());
})
.catch((error: Error) => {
toast.error(error.message);
})
.finally(async () => {
await fetchModules();
setNumChanges(0);
});
};
const renderContent = () => {
const content = useMemo(() => {
if (!modules) {
return <FormLoader onRetry={fetchModules} errorMessage={error?.message} />;
return (
<FormLoader onRetry={fetchModules} errorMessage={error?.message || ''} />
);
}
if (modules.length === 0) {
@@ -165,13 +184,6 @@ const Modules = () => {
);
}
const colorStatus = (status: number) => {
if (status === 1) {
return <div style={{ color: 'red' }}>Pending Activation</div>;
}
return <div style={{ color: '#00FF7F' }}>Activated</div>;
};
return (
<>
<Box mb={2} color="warning.main">
@@ -214,7 +226,9 @@ const Modules = () => {
<Cell>{mi.author}</Cell>
<Cell>{mi.version}</Cell>
<Cell>{mi.message}</Cell>
<Cell>{colorStatus(mi.status)}</Cell>
<Cell>
<ColorStatus status={mi.status} />
</Cell>
</Row>
))}
</Body>
@@ -248,12 +262,22 @@ const Modules = () => {
</Box>
</>
);
};
}, [
modules,
fetchModules,
error,
modules_theme,
editModuleItem,
LL,
numChanges,
onCancel,
saveModules
]);
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{renderContent()}
{content}
{selectedModuleItem && (
<ModulesDialog
open={dialogOpen}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done';
@@ -10,7 +10,7 @@ import {
DialogActions,
DialogContent,
DialogTitle,
Grid2 as Grid,
Grid,
TextField
} from '@mui/material';
@@ -37,25 +37,35 @@ const ModulesDialog = ({
const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<ModuleItem>(selectedItem);
const updateFormValue = updateValue(setEditItem);
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
);
// Sync form state when dialog opens or selected item changes
useEffect(() => {
if (open) {
setEditItem(selectedItem);
}
}, [open, selectedItem]);
const close = () => {
onClose();
};
const save = () => {
const handleSave = useCallback(() => {
onSave(editItem);
};
}, [editItem, onSave]);
const dialogTitle = useMemo(
() => `${LL.EDIT()} ${editItem.key}`,
[LL, editItem.key]
);
return (
<Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}>
<DialogTitle>{LL.EDIT() + ' ' + editItem.key}</DialogTitle>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent dividers>
<Grid container>
<BlockFormControlLabel
@@ -85,7 +95,7 @@ const ModulesDialog = ({
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={close}
onClick={onClose}
color="secondary"
>
{LL.CANCEL()}
@@ -93,7 +103,7 @@ const ModulesDialog = ({
<Button
startIcon={<DoneIcon />}
variant="outlined"
onClick={save}
onClick={handleSave}
color="primary"
>
{LL.UPDATE()}

View File

@@ -1,3 +1,5 @@
import { memo } from 'react';
import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined';
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
@@ -10,33 +12,39 @@ import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined';
import type { SvgIconProps } from '@mui/material';
type OptionType =
export type OptionType =
| 'deleted'
| 'readonly'
| 'web_exclude'
| 'api_mqtt_exclude'
| 'favorite';
const OPTION_ICONS: {
[type in OptionType]: [
React.ComponentType<SvgIconProps>,
React.ComponentType<SvgIconProps>
];
} = {
type IconPair = [
React.ComponentType<SvgIconProps>,
React.ComponentType<SvgIconProps>
];
const OPTION_ICONS: Record<OptionType, IconPair> = {
deleted: [DeleteForeverIcon, DeleteOutlineIcon],
readonly: [EditOffOutlinedIcon, EditOutlinedIcon],
web_exclude: [VisibilityOffOutlinedIcon, VisibilityOutlinedIcon],
api_mqtt_exclude: [CommentsDisabledOutlinedIcon, InsertCommentOutlinedIcon],
favorite: [StarIcon, StarOutlineIcon]
} as const;
const ICON_SIZE = 16;
const ICON_SX = { fontSize: ICON_SIZE, verticalAlign: 'middle' } as const;
export interface OptionIconProps {
readonly type: OptionType;
readonly isSet: boolean;
}
const OptionIcon = ({ type, isSet }: OptionIconProps) => {
const [SetIcon, UnsetIcon] = OPTION_ICONS[type];
const Icon = isSet ? SetIcon : UnsetIcon;
return <Icon {...(isSet && { color: 'primary' })} sx={ICON_SX} />;
};
const OptionIcon = ({ type, isSet }: { type: OptionType; isSet: boolean }) => {
const Icon = OPTION_ICONS[type][isSet ? 0 : 1];
return isSet ? (
<Icon color="primary" sx={{ fontSize: 16, verticalAlign: 'middle' }} />
) : (
<Icon sx={{ fontSize: 16, verticalAlign: 'middle' }} />
);
};
export default OptionIcon;
export default memo(OptionIcon);

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useBlocker } from 'react-router';
import { toast } from 'react-toastify';
@@ -27,6 +27,7 @@ import {
useLayoutTitle
} from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils';
import { readSchedule, writeSchedule } from '../../api/app';
import SettingsSchedulerDialog from './SchedulerDialog';
@@ -34,6 +35,76 @@ import { ScheduleFlag } from './types';
import type { Schedule, ScheduleItem } from './types';
import { schedulerItemValidation } from './validators';
// Constants
const INTERVAL_DELAY = 30000; // 30 seconds
const MIN_ID = -100;
const MAX_ID = 100;
const ICON_SIZE = 16;
const SCHEDULE_FLAG_THRESHOLD = 127;
const REFERENCE_YEAR = 2017;
const REFERENCE_MONTH = '01';
const LOG_2 = Math.log(2);
// Days of week starting from Monday (1-7)
const WEEK_DAYS = [1, 2, 3, 4, 5, 6, 7] as const;
const DEFAULT_SCHEDULE_ITEM: Omit<ScheduleItem, 'id' | 'o_id'> = {
active: false,
deleted: false,
flags: ScheduleFlag.SCHEDULE_DAY,
time: '',
cmd: '',
value: '',
name: ''
};
const scheduleTheme = {
Table: `
--data-table-library_grid-template-columns: 36px 210px 100px 192px repeat(1, minmax(100px, 1fr)) 160px;
`,
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(2) {
text-align: center;
}
&:nth-of-type(1) {
text-align: center;
}
`,
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
.th {
border-bottom: 1px solid #565656;
height: 36px;
}
`,
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
.td {
border-bottom: 1px solid #565656;
}
&:hover .td {
background-color: #177ac9;
}
`
};
const scheduleTypeLabels: Record<number, string> = {
[ScheduleFlag.SCHEDULE_IMMEDIATE]: 'Immediate',
[ScheduleFlag.SCHEDULE_TIMER]: 'Timer',
[ScheduleFlag.SCHEDULE_CONDITION]: 'Condition',
[ScheduleFlag.SCHEDULE_ONCHANGE]: 'On Change'
};
const Scheduler = () => {
const { LL, locale } = useI18nContext();
const [numChanges, setNumChanges] = useState<number>(0);
@@ -60,7 +131,7 @@ const Scheduler = () => {
}
);
function hasScheduleChanged(si: ScheduleItem) {
const hasScheduleChanged = useCallback((si: ScheduleItem) => {
return (
si.id !== si.o_id ||
(si.name || '') !== (si.o_name || '') ||
@@ -71,85 +142,56 @@ const Scheduler = () => {
si.cmd !== si.o_cmd ||
si.value !== si.o_value
);
}
}, []);
const intervalCallback = useCallback(() => {
if (numChanges === 0) {
void fetchSchedule();
}
}, [numChanges, fetchSchedule]);
useInterval(intervalCallback, INTERVAL_DELAY);
useEffect(() => {
const formatter = new Intl.DateTimeFormat(locale, {
weekday: 'short',
timeZone: 'UTC'
});
const days = [1, 2, 3, 4, 5, 6, 7].map((day) => {
const dd = day < 10 ? `0${day}` : day;
return new Date(`2017-01-${dd}T00:00:00+00:00`);
const days = WEEK_DAYS.map((day) => {
const dayStr = String(day).padStart(2, '0');
return new Date(
`${REFERENCE_YEAR}-${REFERENCE_MONTH}-${dayStr}T00:00:00+00:00`
);
});
setDow(days.map((date) => formatter.format(date)));
}, [locale]);
const schedule_theme = useTheme({
Table: `
--data-table-library_grid-template-columns: 36px 210px 100px 192px repeat(1, minmax(100px, 1fr)) 160px;
`,
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(2) {
text-align: center;
}
&:nth-of-type(1) {
text-align: center;
}
`,
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
.th {
border-bottom: 1px solid #565656;
height: 36px;
}
`,
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
.td {
border-bottom: 1px solid #565656;
}
&:hover .td {
background-color: #177ac9;
}
`
});
const schedule_theme = useTheme(scheduleTheme);
const saveSchedule = async () => {
await updateSchedule({
schedule: schedule
.filter((si) => !si.deleted)
.map((condensed_si) => ({
id: condensed_si.id,
active: condensed_si.active,
flags: condensed_si.flags,
time: condensed_si.time,
cmd: condensed_si.cmd,
value: condensed_si.value,
name: condensed_si.name
}))
})
.then(() => {
toast.success(LL.SCHEDULE_UPDATED());
})
.catch((error: Error) => {
toast.error(error.message);
})
.finally(async () => {
await fetchSchedule();
setNumChanges(0);
const saveSchedule = useCallback(async () => {
try {
await updateSchedule({
schedule: schedule
.filter((si: ScheduleItem) => !si.deleted)
.map((condensed_si: ScheduleItem) => ({
id: condensed_si.id,
active: condensed_si.active,
flags: condensed_si.flags,
time: condensed_si.time,
cmd: condensed_si.cmd,
value: condensed_si.value,
name: condensed_si.name
}))
});
};
toast.success(LL.SCHEDULE_UPDATED());
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
toast.error(message);
} finally {
await fetchSchedule();
setNumChanges(0);
}
}, [LL, schedule, updateSchedule, fetchSchedule]);
const editScheduleItem = useCallback((si: ScheduleItem) => {
setCreating(false);
@@ -160,93 +202,93 @@ const Scheduler = () => {
}
}, []);
const onDialogClose = () => {
const onDialogClose = useCallback(() => {
setDialogOpen(false);
};
}, []);
const onDialogCancel = async () => {
const onDialogCancel = useCallback(async () => {
await fetchSchedule().then(() => {
setNumChanges(0);
});
};
}, [fetchSchedule]);
const onDialogSave = (updatedItem: ScheduleItem) => {
setDialogOpen(false);
void updateState(readSchedule(), (data: ScheduleItem[]) => {
const new_data = creating
? [
...data.filter((si) => creating || si.o_id !== updatedItem.o_id),
updatedItem
]
: data.map((si) =>
si.id === updatedItem.id ? { ...si, ...updatedItem } : si
);
const onDialogSave = useCallback(
(updatedItem: ScheduleItem) => {
setDialogOpen(false);
void updateState(readSchedule(), (data: ScheduleItem[]) => {
const new_data = creating
? [...data, updatedItem]
: data.map((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 = () => {
const addScheduleItem = useCallback(() => {
setCreating(true);
setSelectedScheduleItem({
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
active: false,
deleted: false,
flags: ScheduleFlag.SCHEDULE_DAY,
time: '',
cmd: '',
value: '',
name: ''
});
const newItem: ScheduleItem = {
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
...DEFAULT_SCHEDULE_ITEM
};
setSelectedScheduleItem(newItem);
setDialogOpen(true);
};
}, []);
const renderSchedule = () => {
if (!schedule) {
return <FormLoader onRetry={fetchSchedule} errorMessage={error?.message} />;
}
const filteredAndSortedSchedule = useMemo(
() =>
schedule
.filter((si: ScheduleItem) => !si.deleted)
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags),
[schedule]
);
const dayBox = (si: ScheduleItem, flag: number) => (
<>
<Box>
<Typography
sx={{ fontSize: 11 }}
color={(si.flags & flag) === flag ? 'primary' : 'grey'}
>
{dow[Math.log(flag) / Math.log(2)]}
</Typography>
</Box>
<Divider orientation="vertical" flexItem />
</>
);
const dayBox = useCallback(
(si: ScheduleItem, flag: number) => {
const dayIndex = Math.log(flag) / LOG_2;
const isActive = (si.flags & flag) === flag;
const scheduleType = (si: ScheduleItem) => (
return (
<>
<Box>
<Typography sx={{ fontSize: 11 }} color={isActive ? 'primary' : 'grey'}>
{dow[dayIndex]}
</Typography>
</Box>
<Divider orientation="vertical" flexItem />
</>
);
},
[dow]
);
const scheduleType = useCallback((si: ScheduleItem) => {
const label = scheduleTypeLabels[si.flags];
return (
<Box>
<Typography sx={{ fontSize: 11 }} color="primary">
{si.flags === ScheduleFlag.SCHEDULE_IMMEDIATE ? (
<>Immediate</>
) : si.flags === ScheduleFlag.SCHEDULE_TIMER ? (
<>Timer</>
) : si.flags === ScheduleFlag.SCHEDULE_CONDITION ? (
<>Condition</>
) : si.flags === ScheduleFlag.SCHEDULE_ONCHANGE ? (
<>On Change</>
) : (
<></>
)}
{label || ''}
</Typography>
</Box>
);
}, []);
const renderSchedule = useCallback(() => {
if (!schedule) {
return (
<FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} />
);
}
return (
<Table
data={{
nodes: schedule
.filter((si) => !si.deleted)
.sort((a, b) => a.flags - b.flags)
}}
data={{ nodes: filteredAndSortedSchedule }}
theme={schedule_theme}
layout={{ custom: true }}
>
@@ -266,22 +308,15 @@ const Scheduler = () => {
{tableList.map((si: ScheduleItem) => (
<Row key={si.id} item={si} onClick={() => editScheduleItem(si)}>
<Cell stiff>
{si.active ? (
<CircleIcon
color="success"
sx={{ fontSize: 16, verticalAlign: 'middle' }}
/>
) : (
<CircleIcon
color="error"
sx={{ fontSize: 16, verticalAlign: 'middle' }}
/>
)}
<CircleIcon
color={si.active ? 'success' : 'error'}
sx={{ fontSize: ICON_SIZE, verticalAlign: 'middle' }}
/>
</Cell>
<Cell stiff>
<Stack spacing={0.5} direction="row">
<Divider orientation="vertical" flexItem />
{si.flags > 127 ? (
{si.flags > SCHEDULE_FLAG_THRESHOLD ? (
scheduleType(si)
) : (
<>
@@ -307,7 +342,17 @@ const Scheduler = () => {
)}
</Table>
);
};
}, [
schedule,
error,
fetchSchedule,
filteredAndSortedSchedule,
schedule_theme,
editScheduleItem,
LL,
dayBox,
scheduleType
]);
return (
<SectionContent>
@@ -329,7 +374,7 @@ const Scheduler = () => {
/>
)}
<Box mt={1} display="flex" flexWrap="wrap">
<Box display="flex" flexWrap="wrap">
<Box flexGrow={1}>
{numChanges !== 0 && (
<ButtonRow>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -13,7 +13,7 @@ import {
DialogActions,
DialogContent,
DialogTitle,
Grid2 as Grid,
Grid,
TextField,
ToggleButton,
ToggleButtonGroup,
@@ -31,6 +31,34 @@ import { validate } from 'validators';
import { ScheduleFlag } from './types';
import type { ScheduleItem } from './types';
// Constants
const FLAG_MASK_127 = 127;
const SCHEDULE_TYPE_THRESHOLD = 128;
const DEFAULT_TIME = '00:00';
const TYPOGRAPHY_FONT_SIZE = 10;
// Day of week flag configuration (static, defined outside component)
const DAY_FLAGS = [
{ value: '2', flag: ScheduleFlag.SCHEDULE_MON },
{ value: '4', flag: ScheduleFlag.SCHEDULE_TUE },
{ value: '8', flag: ScheduleFlag.SCHEDULE_WED },
{ value: '16', flag: ScheduleFlag.SCHEDULE_THU },
{ value: '32', flag: ScheduleFlag.SCHEDULE_FRI },
{ value: '64', flag: ScheduleFlag.SCHEDULE_SAT },
{ value: '1', flag: ScheduleFlag.SCHEDULE_SUN }
] as const;
// Day of week flag values array (static)
const FLAG_VALUES = [
ScheduleFlag.SCHEDULE_SUN,
ScheduleFlag.SCHEDULE_MON,
ScheduleFlag.SCHEDULE_TUE,
ScheduleFlag.SCHEDULE_WED,
ScheduleFlag.SCHEDULE_THU,
ScheduleFlag.SCHEDULE_FRI,
ScheduleFlag.SCHEDULE_SAT
] as const;
interface SchedulerDialogProps {
open: boolean;
creating: boolean;
@@ -53,107 +81,163 @@ const SchedulerDialog = ({
const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<ScheduleItem>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [scheduleType, setScheduleType] = useState<ScheduleFlag>();
const updateFormValue = updateValue(setEditItem);
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
);
useEffect(() => {
if (open) {
setFieldErrors(undefined);
setEditItem(selectedItem);
// set the flags based on type when page is loaded...
// Set the flags based on type when page is loaded:
// 0-127 is day schedule
// 128 is timer
// 129 is on change
// 130 is on condition
// 132 is immediate
setScheduleType(
selectedItem.flags < 128 ? ScheduleFlag.SCHEDULE_DAY : selectedItem.flags
selectedItem.flags < SCHEDULE_TYPE_THRESHOLD
? ScheduleFlag.SCHEDULE_DAY
: selectedItem.flags
);
}
}, [open, selectedItem]);
const save = async () => {
try {
setFieldErrors(undefined);
await validate(validator, editItem);
onSave(editItem);
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
};
const saveandactivate = async () => {
editItem.active = true;
try {
setFieldErrors(undefined);
await validate(validator, editItem);
onSave(editItem);
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
};
const remove = () => {
editItem.deleted = true;
onSave(editItem);
};
const getFlagDOWnumber = (newFlag: string[]) => {
let new_flag = 0;
for (const entry of newFlag) {
new_flag |= Number(entry);
}
return new_flag & 127;
};
const getFlagDOWstring = (f: number) => {
const new_flags: string[] = [];
if ((f & 129) === 1) {
new_flags.push('1');
}
if ((f & 130) === 2) {
new_flags.push('2');
}
if ((f & 4) === 4) {
new_flags.push('4');
}
if ((f & 8) === 8) {
new_flags.push('8');
}
if ((f & 16) === 16) {
new_flags.push('16');
}
if ((f & 32) === 32) {
new_flags.push('32');
}
if ((f & 64) === 64) {
new_flags.push('64');
}
return new_flags;
};
const showDOW = (si: ScheduleItem, flag: number) => (
<Typography
sx={{ fontSize: 10 }}
color={(si.flags & flag) === flag ? 'primary' : 'grey'}
>
{dow[Math.log(flag) / Math.log(2)]}
</Typography>
// Helper function to handle save operations
const handleSave = useCallback(
async (itemToSave: ScheduleItem) => {
try {
setFieldErrors(undefined);
await validate(validator, itemToSave);
onSave(itemToSave);
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
},
[validator, onSave]
);
const handleClose = (_event, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
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 ? 0 : flag;
setEditItem((prev) => ({ ...prev, time: '', flags: newFlags }));
}
},
[]
);
const handleDOWChange = useCallback(
(_event: React.SyntheticEvent<HTMLElement>, flags: string[]) => {
const newFlags = 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(() => {
if (scheduleType === ScheduleFlag.SCHEDULE_TIMER) return LL.TIMER(1);
if (scheduleType === ScheduleFlag.SCHEDULE_CONDITION) return LL.CONDITION();
if (scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE) return LL.ONCHANGE();
if (scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE) return LL.IMMEDIATE();
return LL.TIME(1);
}, [scheduleType, LL]);
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>
{creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()}&nbsp;
{creating ? `${LL.ADD(1)} ${LL.NEW(0)}` : LL.EDIT()}&nbsp;
{LL.SCHEDULE(1)}
</DialogTitle>
<DialogContent dividers>
@@ -163,47 +247,27 @@ const SchedulerDialog = ({
value={scheduleType}
exclusive
disabled={!creating}
onChange={(_event, flag: ScheduleFlag) => {
if (flag !== null) {
setFieldErrors(undefined); // clear any validation errors
setScheduleType(flag);
// wipe the time field when changing the schedule type
setEditItem({ ...editItem, time: '' });
// set the flags based on type
// 0-127 is day schedule
// 128 is timer
// 129 is on change
// 130 is on condition
// 132 is immediate
setEditItem(
flag === ScheduleFlag.SCHEDULE_DAY
? { ...editItem, flags: 0 }
: { ...editItem, flags: flag }
);
}
}}
onChange={handleScheduleTypeChange}
>
<ToggleButton value={ScheduleFlag.SCHEDULE_DAY}>
<Typography
sx={{ fontSize: 10 }}
color={scheduleType === ScheduleFlag.SCHEDULE_DAY ? 'primary' : 'grey'}
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={isDaySchedule ? 'primary' : 'grey'}
>
{LL.SCHEDULE(0)}
</Typography>
</ToggleButton>
<ToggleButton value={ScheduleFlag.SCHEDULE_TIMER}>
<Typography
sx={{ fontSize: 10 }}
color={
scheduleType === ScheduleFlag.SCHEDULE_TIMER ? 'primary' : 'grey'
}
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={isTimerSchedule ? 'primary' : 'grey'}
>
{LL.TIMER(0)}
</Typography>
</ToggleButton>
<ToggleButton value={ScheduleFlag.SCHEDULE_ONCHANGE}>
<Typography
sx={{ fontSize: 10 }}
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={
scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE ? 'primary' : 'grey'
}
@@ -213,7 +277,7 @@ const SchedulerDialog = ({
</ToggleButton>
<ToggleButton value={ScheduleFlag.SCHEDULE_CONDITION}>
<Typography
sx={{ fontSize: 10 }}
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={
scheduleType === ScheduleFlag.SCHEDULE_CONDITION ? 'primary' : 'grey'
}
@@ -223,50 +287,30 @@ const SchedulerDialog = ({
</ToggleButton>
<ToggleButton value={ScheduleFlag.SCHEDULE_IMMEDIATE}>
<Typography
sx={{ fontSize: 10 }}
color={
scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE ? 'primary' : 'grey'
}
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={isImmediateSchedule ? 'primary' : 'grey'}
>
{LL.IMMEDIATE()}
</Typography>
</ToggleButton>
</ToggleButtonGroup>
{scheduleType === ScheduleFlag.SCHEDULE_DAY && (
{isDaySchedule && (
<ToggleButtonGroup
size="small"
color="secondary"
value={getFlagDOWstring(editItem.flags)}
onChange={(_event, flag: string[]) => {
setEditItem({ ...editItem, flags: getFlagDOWnumber(flag) });
}}
value={dowFlags}
onChange={handleDOWChange}
>
<ToggleButton value="2">
{showDOW(editItem, ScheduleFlag.SCHEDULE_MON)}
</ToggleButton>
<ToggleButton value="4">
{showDOW(editItem, ScheduleFlag.SCHEDULE_TUE)}
</ToggleButton>
<ToggleButton value="8">
{showDOW(editItem, ScheduleFlag.SCHEDULE_WED)}
</ToggleButton>
<ToggleButton value="16">
{showDOW(editItem, ScheduleFlag.SCHEDULE_THU)}
</ToggleButton>
<ToggleButton value="32">
{showDOW(editItem, ScheduleFlag.SCHEDULE_FRI)}
</ToggleButton>
<ToggleButton value="64">
{showDOW(editItem, ScheduleFlag.SCHEDULE_SAT)}
</ToggleButton>
<ToggleButton value="1">
{showDOW(editItem, ScheduleFlag.SCHEDULE_SUN)}
</ToggleButton>
{DAY_FLAGS.map(({ value, flag }) => (
<ToggleButton key={value} value={value}>
{DayOfWeekButton(flag)}
</ToggleButton>
))}
</ToggleButtonGroup>
)}
{scheduleType !== ScheduleFlag.SCHEDULE_IMMEDIATE && (
{!isImmediateSchedule && (
<>
<Grid container>
<BlockFormControlLabel
@@ -281,22 +325,17 @@ const SchedulerDialog = ({
/>
</Grid>
<Grid container>
{scheduleType === ScheduleFlag.SCHEDULE_DAY ||
scheduleType === ScheduleFlag.SCHEDULE_TIMER ? (
{needsTimeField ? (
<>
<TextField
name="time"
type="time"
label={
scheduleType === ScheduleFlag.SCHEDULE_TIMER
? LL.TIMER(1)
: LL.TIME(1)
}
value={editItem.time === '' ? '00:00' : editItem.time}
label={timeFieldLabel}
value={timeFieldValue}
margin="normal"
onChange={updateFormValue}
/>
{scheduleType === ScheduleFlag.SCHEDULE_TIMER && (
{isTimerSchedule && (
<Box color="warning.main" ml={2} mt={4}>
<Typography variant="body2">
{LL.SCHEDULER_HELP_2()}
@@ -307,16 +346,10 @@ const SchedulerDialog = ({
) : (
<TextField
name="time"
label={
scheduleType === ScheduleFlag.SCHEDULE_CONDITION
? LL.CONDITION()
: scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE
? LL.ONCHANGE()
: LL.IMMEDIATE()
}
label={timeFieldLabel}
multiline
fullWidth
value={editItem.time === '00:00' ? '' : editItem.time}
value={timeFieldValue}
margin="normal"
onChange={updateFormValue}
/>
@@ -325,7 +358,7 @@ const SchedulerDialog = ({
</>
)}
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="cmd"
label={LL.COMMAND(0)}
multiline
@@ -344,7 +377,7 @@ const SchedulerDialog = ({
onChange={updateFormValue}
/>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="name"
label={LL.NAME(0) + ' (' + LL.OPTIONAL() + ')'}
value={editItem.name}
@@ -383,7 +416,7 @@ const SchedulerDialog = ({
>
{creating ? LL.ADD(0) : LL.UPDATE()}
</Button>
{scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE && editItem.cmd !== '' && (
{isImmediateSchedule && editItem.cmd !== '' && (
<Button
startIcon={<PlayArrowIcon />}
variant="outlined"

View File

@@ -1,4 +1,4 @@
import { useContext, useState } from 'react';
import { useCallback, useContext, useMemo, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined';
@@ -49,6 +49,74 @@ import {
temperatureSensorItemValidation
} from './validators';
// Constants
const MS_PER_SECOND = 1000;
const MS_PER_MINUTE = 60 * MS_PER_SECOND;
const MS_PER_HOUR = 60 * MS_PER_MINUTE;
const MS_PER_DAY = 24 * MS_PER_HOUR;
const MIN_TEMP_ID = -100;
const MAX_TEMP_ID = 100;
const GPIO_25 = 25;
const GPIO_26 = 26;
const HEADER_BUTTON_STYLE: React.CSSProperties = {
fontSize: '14px',
justifyContent: 'flex-start'
};
const HEADER_BUTTON_STYLE_END: React.CSSProperties = {
fontSize: '14px',
justifyContent: 'flex-end'
};
const common_theme = {
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
.th {
border-bottom: 1px solid #565656;
height: 36px;
}
`,
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
.td {
padding: 8px;
border-bottom: 1px solid #565656;
}
&:hover .td {
background-color: #177ac9;
color: white;
}
`,
Cell: `
&:last-of-type {
text-align: right;
},
`
};
const temperature_theme_config = {
Table: `
--data-table-library_grid-template-columns: minmax(0, 1fr) 35%;
`
};
const analog_theme_config = {
Table: `
--data-table-library_grid-template-columns: 80px repeat(1, minmax(0, 1fr)) 120px 110px;
`
};
const Sensors = () => {
const { LL } = useI18nContext();
const { me } = useContext(AuthenticatedContext);
@@ -59,18 +127,22 @@ const Sensors = () => {
const [temperatureDialogOpen, setTemperatureDialogOpen] = useState<boolean>(false);
const [analogDialogOpen, setAnalogDialogOpen] = useState<boolean>(false);
const [creating, setCreating] = useState<boolean>(false);
const firstAvailableGPIO = useRef<number>(undefined);
const { data: sensorData, send: fetchSensorData } = useRequest(
() => readSensorData(),
{
initialData: {
ts: [],
as: [],
analog_enabled: false,
platform: 'ESP32'
}
const { data: sensorData, send: fetchSensorData } = useRequest(readSensorData, {
initialData: {
ts: [],
as: [],
analog_enabled: false,
available_gpios: [] as number[],
platform: 'ESP32'
}
);
}).onSuccess((event) => {
// store the first available GPIO in a ref
if (event.data.available_gpios.length > 0) {
firstAvailableGPIO.current = event.data.available_gpios[0];
}
});
const { send: sendTemperatureSensor } = useRequest(
(data: WriteTemperatureSensor) => writeTemperatureSensor(data),
@@ -86,118 +158,18 @@ const Sensors = () => {
}
);
useInterval(() => {
const intervalCallback = useCallback(() => {
if (!temperatureDialogOpen && !analogDialogOpen) {
void fetchSensorData();
}
});
}, [temperatureDialogOpen, analogDialogOpen, fetchSensorData]);
const common_theme = useTheme({
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
.th {
border-bottom: 1px solid #565656;
}
.th {
height: 36px;
}
`,
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
.td {
padding: 8px;
border-bottom: 1px solid #565656;
}
&:hover .td {
background-color: #177ac9;
}
`,
Cell: `
&:last-of-type {
text-align: right;
},
`
});
useInterval(intervalCallback);
const temperature_theme = useTheme([
common_theme,
{
Table: `
--data-table-library_grid-template-columns: minmax(0, 1fr) 35%;
`
}
]);
const temperature_theme = useTheme([common_theme, temperature_theme_config]);
const analog_theme = useTheme([common_theme, analog_theme_config]);
const analog_theme = useTheme([
common_theme,
{
Table: `
--data-table-library_grid-template-columns: 80px repeat(1, minmax(0, 1fr)) 120px 110px;
`
}
]);
const RenderTemperatureSensors = () => (
<Table
data={{ nodes: sensorData.ts }}
theme={temperature_theme}
sort={temperature_sort}
layout={{ custom: true }}
>
{(tableList: TemperatureSensor[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell resize>
<Button
fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
endIcon={getSortIcon(temperature_sort.state, 'NAME')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
}
>
{LL.NAME(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-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 key={ts.id} item={ts} onClick={() => updateTemperatureSensor(ts)}>
<Cell>{ts.n}</Cell>
<Cell>{formatValue(ts.t, ts.u)}</Cell>
</Row>
))}
</Body>
</>
)}
</Table>
);
const getSortIcon = (state: State, sortKey: unknown) => {
const getSortIcon = useCallback((state: State, sortKey: unknown) => {
if (state.sortKey === sortKey && state.reverse) {
return <KeyboardArrowDownOutlinedIcon />;
}
@@ -205,7 +177,7 @@ const Sensors = () => {
return <KeyboardArrowUpOutlinedIcon />;
}
return <UnfoldMoreOutlinedIcon />;
};
}, []);
const analog_sort = useSort(
{ nodes: sensorData.as },
@@ -218,11 +190,20 @@ const Sensors = () => {
},
sortToggleType: SortToggleType.AlternateWithReset,
sortFns: {
GPIO: (array) => array.sort((a, b) => a.g - b.g),
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
NAME: (array) => array.sort((a, b) => a.n.localeCompare(b.n)),
TYPE: (array) => array.sort((a, b) => a.t - b.t),
VALUE: (array) => array.sort((a, b) => a.v - b.v)
GPIO: (array) =>
[...array].sort(
(a, b) => ((a as AnalogSensor)?.g ?? 0) - ((b as AnalogSensor)?.g ?? 0)
),
NAME: (array) =>
[...array].sort((a, b) =>
((a as AnalogSensor)?.n ?? '').localeCompare(
(b as AnalogSensor)?.n ?? ''
)
),
TYPE: (array) =>
[...array].sort((a, b) => (a as AnalogSensor).t - (b as AnalogSensor).t),
VALUE: (array) =>
[...array].sort((a, b) => (a as AnalogSensor).v - (b as AnalogSensor).v)
}
}
);
@@ -238,226 +219,349 @@ const Sensors = () => {
},
sortToggleType: SortToggleType.AlternateWithReset,
sortFns: {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
NAME: (array) => array.sort((a, b) => a.n.localeCompare(b.n)),
VALUE: (array) => array.sort((a, b) => a.t - b.t)
NAME: (array) =>
[...array].sort((a, b) =>
(a as TemperatureSensor).n.localeCompare((b as TemperatureSensor).n)
),
VALUE: (array) =>
[...array].sort(
(a, b) =>
((a as TemperatureSensor).t ?? 0) - ((b as TemperatureSensor).t ?? 0)
)
}
}
);
useLayoutTitle(LL.SENSORS());
const formatDurationMin = (duration_min: number) => {
const days = Math.trunc((duration_min * 60000) / 86400000);
const hours = Math.trunc((duration_min * 60000) / 3600000) % 24;
const minutes = Math.trunc((duration_min * 60000) / 60000) % 60;
const formatDurationMin = useCallback(
(duration_min: number) => {
const totalMs = duration_min * MS_PER_MINUTE;
const days = Math.trunc(totalMs / MS_PER_DAY);
const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24;
const minutes = Math.trunc(totalMs / MS_PER_MINUTE) % 60;
let formatted = '';
if (days) {
formatted += LL.NUM_DAYS({ num: days }) + ' ';
}
if (hours) {
formatted += LL.NUM_HOURS({ num: hours }) + ' ';
}
if (minutes) {
formatted += LL.NUM_MINUTES({ num: minutes });
}
return formatted;
};
const parts: string[] = [];
if (days > 0) {
parts.push(LL.NUM_DAYS({ num: days }));
}
if (hours > 0) {
parts.push(LL.NUM_HOURS({ num: hours }));
}
if (minutes > 0) {
parts.push(LL.NUM_MINUTES({ num: minutes }));
}
return parts.join(' ');
},
[LL]
);
function formatValue(value: unknown, uom: DeviceValueUOM) {
if (value === undefined) {
return '';
}
if (typeof value !== 'number') {
return value as string;
}
switch (uom) {
case DeviceValueUOM.HOURS:
return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 });
case DeviceValueUOM.MINUTES:
return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 });
case DeviceValueUOM.SECONDS:
return LL.NUM_SECONDS({ num: value });
case DeviceValueUOM.NONE:
return new Intl.NumberFormat().format(value);
case DeviceValueUOM.DEGREES:
case DeviceValueUOM.DEGREES_R:
case DeviceValueUOM.FAHRENHEIT:
return (
new Intl.NumberFormat(undefined, {
minimumFractionDigits: 1
}).format(value) +
' ' +
DeviceValueUOM_s[uom]
);
default:
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
}
}
const formatValue = useCallback(
(value: unknown, uom: DeviceValueUOM) => {
if (value === undefined) {
return '';
}
if (typeof value !== 'number') {
return value as string;
}
switch (uom) {
case DeviceValueUOM.HOURS:
return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 });
case DeviceValueUOM.MINUTES:
return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 });
case DeviceValueUOM.SECONDS:
return LL.NUM_SECONDS({ num: value });
case DeviceValueUOM.NONE:
return new Intl.NumberFormat().format(value);
case DeviceValueUOM.DEGREES:
case DeviceValueUOM.DEGREES_R:
case DeviceValueUOM.FAHRENHEIT:
return (
new Intl.NumberFormat(undefined, {
minimumFractionDigits: 1
}).format(value) +
' ' +
DeviceValueUOM_s[uom]
);
default:
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
}
},
[formatDurationMin, LL]
);
const updateTemperatureSensor = (ts: TemperatureSensor) => {
if (me.admin) {
ts.o_n = ts.n;
setSelectedTemperatureSensor(ts);
setTemperatureDialogOpen(true);
}
};
const updateTemperatureSensor = useCallback(
(ts: TemperatureSensor) => {
if (me.admin) {
ts.o_n = ts.n;
setSelectedTemperatureSensor(ts);
setTemperatureDialogOpen(true);
}
},
[me.admin]
);
const onTemperatureDialogClose = () => {
const onTemperatureDialogClose = useCallback(() => {
setTemperatureDialogOpen(false);
void fetchSensorData();
};
}, [fetchSensorData]);
const onTemperatureDialogSave = async (ts: TemperatureSensor) => {
await sendTemperatureSensor({ id: ts.id, name: ts.n, offset: ts.o })
.then(() => {
toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
const onTemperatureDialogSave = useCallback(
async (ts: TemperatureSensor) => {
await sendTemperatureSensor({
id: ts.id,
name: ts.n,
offset: ts.o,
is_system: ts.s
})
.catch(() => {
toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1));
})
.finally(() => {
setTemperatureDialogOpen(false);
setSelectedTemperatureSensor(undefined);
void fetchSensorData();
});
};
.then(() => {
toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
})
.catch(() => {
toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1));
})
.finally(() => {
setTemperatureDialogOpen(false);
setSelectedTemperatureSensor(undefined);
void fetchSensorData();
});
},
[sendTemperatureSensor, LL, fetchSensorData]
);
const updateAnalogSensor = (as: AnalogSensor) => {
if (me.admin) {
setCreating(false);
as.o_n = as.n;
setSelectedAnalogSensor(as);
setAnalogDialogOpen(true);
}
};
const updateAnalogSensor = useCallback(
(as: AnalogSensor) => {
if (me.admin) {
setCreating(false);
as.o_n = as.n;
setSelectedAnalogSensor(as);
setAnalogDialogOpen(true);
}
},
[me.admin]
);
const onAnalogDialogClose = () => {
const onAnalogDialogClose = useCallback(() => {
setAnalogDialogOpen(false);
void fetchSensorData();
};
}, [fetchSensorData]);
const addAnalogSensor = () => {
const addAnalogSensor = useCallback(() => {
if (firstAvailableGPIO.current === undefined) {
toast.error('No available GPIO found');
return;
}
setCreating(true);
setSelectedAnalogSensor({
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
id: Math.floor(Math.random() * (MAX_TEMP_ID - MIN_TEMP_ID) + MIN_TEMP_ID),
n: '',
g: 21, // default GPIO 21 which is safe for all platforms
u: 0,
g: firstAvailableGPIO.current,
u: DeviceValueUOM.NONE,
v: 0,
o: 0,
t: 0,
f: 1,
t: AnalogType.DIGITAL_IN, // default to digital in 1
d: false,
s: false,
o_n: ''
});
setAnalogDialogOpen(true);
};
}, []);
const onAnalogDialogSave = async (as: AnalogSensor) => {
await sendAnalogSensor({
id: as.id,
gpio: as.g,
name: as.n,
offset: as.o,
factor: as.f,
uom: as.u,
type: as.t,
deleted: as.d
})
.then(() => {
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
const onAnalogDialogSave = useCallback(
async (as: AnalogSensor) => {
await sendAnalogSensor({
id: as.id,
gpio: as.g,
name: as.n,
offset: as.o,
factor: as.f,
uom: as.u,
type: as.t,
deleted: as.d,
is_system: as.s
})
.catch(() => {
toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1));
})
.finally(() => {
setAnalogDialogOpen(false);
setSelectedAnalogSensor(undefined);
void fetchSensorData();
});
};
.then(() => {
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
})
.catch(() => {
toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1));
})
.finally(() => {
setAnalogDialogOpen(false);
setSelectedAnalogSensor(undefined);
void fetchSensorData();
});
},
[sendAnalogSensor, LL, fetchSensorData]
);
const RenderAnalogSensors = () => (
<Table
data={{ nodes: sensorData.as }}
theme={analog_theme}
sort={analog_sort}
layout={{ custom: true }}
>
{(tableList: AnalogSensor[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell stiff>
<Button
fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
endIcon={getSortIcon(analog_sort.state, 'GPIO')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })}
const RenderAnalogSensors = useMemo(
() => (
<Table
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)}
>
GPIO
</Button>
</HeaderCell>
<HeaderCell resize>
<Button
fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
endIcon={getSortIcon(analog_sort.state, 'NAME')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
<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>
),
[
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)}
>
{LL.NAME(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
endIcon={getSortIcon(analog_sort.state, 'TYPE')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
>
{LL.TYPE(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-end' }}
endIcon={getSortIcon(analog_sort.state, 'VALUE')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })}
>
{LL.VALUE(0)}
</Button>
</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((a: AnalogSensor) => (
<Row key={a.id} item={a} onClick={() => updateAnalogSensor(a)}>
<Cell stiff>{a.g}</Cell>
<Cell>{a.n}</Cell>
<Cell stiff>{AnalogTypeNames[a.t]} </Cell>
{(a.t === AnalogType.DIGITAL_OUT && a.g !== 25 && a.g !== 26) ||
a.t === AnalogType.DIGITAL_IN ? (
<Cell stiff>{a.v ? LL.ON() : LL.OFF()}</Cell>
) : (
<Cell stiff>{a.t ? formatValue(a.v, a.u) : ''}</Cell>
)}
</Row>
))}
</Body>
</>
)}
</Table>
<Cell>{ts.n}</Cell>
<Cell>{formatValue(ts.t, ts.u)}</Cell>
</Row>
))}
</Body>
</>
)}
</Table>
),
[
temperature_sort,
temperature_theme,
getSortIcon,
sensorData.ts,
LL,
updateTemperatureSensor,
formatValue
]
);
return (
<SectionContent>
<Typography sx={{ pb: 1 }} variant="h6" color="secondary">
<Typography sx={{ pb: 1 }} variant="h6" color="primary">
{LL.TEMP_SENSORS()}
</Typography>
<RenderTemperatureSensors />
{RenderTemperatureSensors}
{selectedTemperatureSensor && (
<DashboardSensorsTemperatureDialog
open={temperatureDialogOpen}
@@ -470,10 +574,10 @@ const Sensors = () => {
)}
/>
)}
<Typography sx={{ pt: 4, pb: 1 }} variant="h6" color="secondary">
<Typography sx={{ pt: 4, pb: 1 }} variant="h6" color="primary">
{LL.ANALOG_SENSORS()}
</Typography>
<RenderAnalogSensors />
{RenderAnalogSensors}
{selectedAnalogSensor && (
<DashboardSensorsAnalogDialog
open={analogDialogOpen}
@@ -481,16 +585,13 @@ const Sensors = () => {
onSave={onAnalogDialogSave}
creating={creating}
selectedItem={selectedAnalogSensor}
validator={analogSensorItemValidation(
sensorData.as,
selectedAnalogSensor,
creating,
sensorData.platform
)}
analogGPIOList={sensorData.available_gpios}
disabledTypeList={sensorData.exclude_types}
validator={analogSensorItemValidation(sensorData.as, selectedAnalogSensor)}
/>
)}
{sensorData?.analog_enabled === true && me.admin && (
<Box mt={1} display="flex" flexWrap="wrap" justifyContent="flex-end">
<Box mt={2} display="flex" flexWrap="wrap" justifyContent="flex-end">
<Button
variant="outlined"
color="primary"

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done';
import RemoveIcon from '@mui/icons-material/RemoveCircleOutline';
import WarningIcon from '@mui/icons-material/Warning';
import {
@@ -10,10 +11,9 @@ import {
DialogActions,
DialogContent,
DialogTitle,
Grid2 as Grid,
Grid,
InputAdornment,
MenuItem,
TextField,
Typography
} from '@mui/material';
@@ -34,6 +34,8 @@ interface DashboardSensorsAnalogDialogProps {
onSave: (as: AnalogSensor) => void;
creating: boolean;
selectedItem: AnalogSensor;
analogGPIOList: number[];
disabledTypeList: number[];
validator: Schema;
}
@@ -43,13 +45,111 @@ const SensorsAnalogDialog = ({
onSave,
creating,
selectedItem,
analogGPIOList,
disabledTypeList,
validator
}: DashboardSensorsAnalogDialogProps) => {
const { LL } = useI18nContext();
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem);
const updateFormValue = updateValue(setEditItem);
const updateFormValue = useMemo(
() =>
updateValue((updater) =>
setEditItem(
(prev) =>
updater(
prev as unknown as Record<string, unknown>
) as unknown as AnalogSensor
)
),
[setEditItem]
);
// Memoize helper functions to check sensor type conditions
const isCounterOrRate = useMemo(
() =>
editItem.t === AnalogType.COUNTER ||
editItem.t === AnalogType.RATE ||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2),
[editItem.t]
);
const isCounter = useMemo(
() =>
editItem.t === AnalogType.COUNTER ||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2),
[editItem.t]
);
const isFreqType = useMemo(
() => editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2,
[editItem.t]
);
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 = useMemo(
() =>
AnalogTypeNames.map((val, i) => ({ name: val, value: i + 1 }))
.sort((a, b) => a.name.localeCompare(b.name))
.map(({ name, value }) => (
<MenuItem
key={name}
value={value}
disabled={disabledTypeList?.includes(value)}
>
{name}
</MenuItem>
)),
[disabledTypeList]
);
const uomMenuItems = useMemo(
() =>
DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
)),
[]
);
const analogGPIOMenuItems = () =>
// add selectedItem.g to the list
[
...(analogGPIOList?.includes(selectedItem.g) || selectedItem.g === undefined
? analogGPIOList
: [selectedItem.g, ...analogGPIOList])
]
.filter((gpio, idx, arr) => arr.indexOf(gpio) === idx)
.sort((a, b) => a - b)
.map((gpio: number) => {
return (
<MenuItem key={gpio} value={gpio}>
{gpio}
</MenuItem>
);
});
// Reset form when dialog opens or selectedItem changes
useEffect(() => {
if (open) {
setFieldErrors(undefined);
@@ -57,13 +157,16 @@ const SensorsAnalogDialog = ({
}
}, [open, selectedItem]);
const handleClose = (_event, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
};
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
},
[onClose]
);
const save = async () => {
const save = useCallback(async () => {
try {
setFieldErrors(undefined);
await validate(validator, editItem);
@@ -71,95 +174,84 @@ const SensorsAnalogDialog = ({
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
};
}, [validator, editItem, onSave]);
const remove = () => {
editItem.d = true;
onSave(editItem);
};
const remove = useCallback(() => {
onSave({ ...editItem, d: true });
}, [editItem, onSave]);
const dialogTitle = useMemo(
() =>
`${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`,
[creating, LL]
);
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>
{creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()}&nbsp;
{LL.ANALOG_SENSOR(0)}
</DialogTitle>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent dividers>
<Grid container spacing={2}>
<ValidatedTextField
name="g"
label="GPIO"
value={editItem.g}
sx={{ width: '9ch' }}
disabled={editItem.s || !creating}
select
onChange={updateFormValue}
>
{analogGPIOMenuItems()}
</ValidatedTextField>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
name="g"
label="GPIO"
sx={{ width: '11ch' }}
value={numberValue(editItem.g)}
type="number"
variant="outlined"
onChange={updateFormValue}
/>
</Grid>
{creating && (
<Grid>
<Box color="warning.main" mt={2}>
<Typography variant="body2">{LL.WARN_GPIO()}</Typography>
</Box>
</Grid>
)}
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
name="n"
label={LL.NAME(0)}
value={editItem.n}
fullWidth
variant="outlined"
onChange={updateFormValue}
/>
</Grid>
<Grid>
<TextField
<ValidatedTextField
name="t"
label={LL.TYPE(0)}
value={editItem.t}
fullWidth
select
onChange={updateFormValue}
disabled={editItem.s}
>
{AnalogTypeNames.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
))}
</TextField>
{analogTypeMenuItems}
</ValidatedTextField>
</Grid>
{editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE && (
{(isCounterOrRate ||
isFreqType ||
editItem.t === AnalogType.ADC ||
editItem.t === AnalogType.TIMER) && (
<Grid>
<TextField
<ValidatedTextField
name="u"
label={LL.UNIT()}
value={editItem.u}
sx={{ width: '15ch' }}
select
onChange={updateFormValue}
disabled={editItem.s}
>
{DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
))}
</TextField>
{uomMenuItems}
</ValidatedTextField>
</Grid>
)}
{editItem.t === AnalogType.ADC && (
<Grid>
<TextField
<ValidatedTextField
name="o"
label={LL.OFFSET()}
value={numberValue(editItem.o)}
type="number"
sx={{ width: '11ch' }}
variant="outlined"
onChange={updateFormValue}
disabled={editItem.s}
slotProps={{
input: {
startAdornment: (
@@ -171,118 +263,152 @@ const SensorsAnalogDialog = ({
/>
</Grid>
)}
{editItem.t === AnalogType.COUNTER && (
{editItem.t === AnalogType.NTC && (
<Grid>
<TextField
<ValidatedTextField
name="o"
label={LL.OFFSET()}
value={numberValue(editItem.o)}
sx={{ width: '11ch' }}
type="number"
onChange={updateFormValue}
disabled={editItem.s}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">°C</InputAdornment>
)
},
htmlInput: { min: '-20', max: '20', step: '0.1' }
}}
/>
</Grid>
)}
{isCounter && (
<Grid>
<ValidatedTextField
name="o"
label={LL.STARTVALUE()}
value={numberValue(editItem.o)}
type="number"
sx={{ width: '11ch' }}
variant="outlined"
onChange={updateFormValue}
disabled={editItem.s}
slotProps={{
htmlInput: { step: '0.001' }
}}
/>
</Grid>
)}
{editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE && (
{editItem.t === AnalogType.RGB && (
<Grid>
<TextField
<ValidatedTextField
name="o"
label={'RGB ' + LL.VALUE(0)}
value={numberValue(editItem.o)}
type="number"
sx={{ width: '11ch' }}
onChange={updateFormValue}
disabled={editItem.s}
/>
</Grid>
)}
{(isCounterOrRate ||
isFreqType ||
editItem.t === AnalogType.ADC ||
editItem.t === AnalogType.TIMER) && (
<Grid>
<ValidatedTextField
name="f"
label={LL.FACTOR()}
value={numberValue(editItem.f)}
sx={{ width: '11ch' }}
sx={{ width: '14ch' }}
type="number"
variant="outlined"
onChange={updateFormValue}
disabled={editItem.s}
slotProps={{
htmlInput: { step: '0.001' }
}}
/>
</Grid>
)}
{editItem.t === AnalogType.DIGITAL_OUT &&
(editItem.g === 25 || editItem.g === 26) && (
{isDACOutGPIO && (
<Grid>
<ValidatedTextField
name="o"
label={LL.VALUE(0)}
value={numberValue(editItem.o)}
sx={{ width: '11ch' }}
type="number"
onChange={updateFormValue}
disabled={editItem.s}
slotProps={{
htmlInput: { min: '0', max: '255', step: '1' }
}}
/>
</Grid>
)}
{isDigitalOutGPIO && (
<>
<Grid>
<TextField
<ValidatedTextField
name="o"
label={LL.VALUE(0)}
value={numberValue(editItem.o)}
sx={{ width: '11ch' }}
type="number"
variant="outlined"
select
onChange={updateFormValue}
slotProps={{
htmlInput: { min: '0', max: '255', step: '1' }
}}
/>
disabled={editItem.s}
>
<MenuItem value={0}>{LL.OFF()}</MenuItem>
<MenuItem value={1}>{LL.ON()}</MenuItem>
</ValidatedTextField>
</Grid>
)}
{editItem.t === AnalogType.DIGITAL_OUT &&
editItem.g !== 25 &&
editItem.g !== 26 && (
<>
<Grid>
<TextField
name="o"
label={LL.VALUE(0)}
value={numberValue(editItem.o)}
select
variant="outlined"
onChange={updateFormValue}
>
<MenuItem value={0}>{LL.OFF()}</MenuItem>
<MenuItem value={1}>{LL.ON()}</MenuItem>
</TextField>
</Grid>
<Grid>
<TextField
name="f"
label={LL.POLARITY()}
value={editItem.f}
sx={{ width: '15ch' }}
select
onChange={updateFormValue}
>
<MenuItem value={1}>{LL.ACTIVEHIGH()}</MenuItem>
<MenuItem value={-1}>{LL.ACTIVELOW()}</MenuItem>
</TextField>
</Grid>
<Grid>
<TextField
name="u"
label={LL.STARTVALUE()}
sx={{ width: '15ch' }}
value={editItem.u}
select
onChange={updateFormValue}
>
<MenuItem value={0}>{LL.UNCHANGED()}</MenuItem>
<MenuItem value={1}>
{LL.ALWAYS()}&nbsp;{LL.OFF()}
</MenuItem>
<MenuItem value={2}>
{LL.ALWAYS()}&nbsp;{LL.ON()}
</MenuItem>
</TextField>
</Grid>
</>
)}
{(editItem.t === AnalogType.PWM_0 ||
editItem.t === AnalogType.PWM_1 ||
editItem.t === AnalogType.PWM_2) && (
<Grid>
<ValidatedTextField
name="f"
label={LL.POLARITY()}
value={editItem.f}
sx={{ width: '15ch' }}
select
onChange={updateFormValue}
disabled={editItem.s}
>
<MenuItem value={1}>{LL.ACTIVEHIGH()}</MenuItem>
<MenuItem value={-1}>{LL.ACTIVELOW()}</MenuItem>
</ValidatedTextField>
</Grid>
<Grid>
<ValidatedTextField
name="u"
label={LL.STARTVALUE()}
sx={{ width: '15ch' }}
value={editItem.u}
select
onChange={updateFormValue}
disabled={editItem.s}
>
<MenuItem value={0}>{LL.UNCHANGED()}</MenuItem>
<MenuItem value={1}>
{LL.ALWAYS()}&nbsp;{LL.OFF()}
</MenuItem>
<MenuItem value={2}>
{LL.ALWAYS()}&nbsp;{LL.ON()}
</MenuItem>
</ValidatedTextField>
</Grid>
</>
)}
{isPWM && (
<>
<Grid>
<TextField
<ValidatedTextField
name="f"
label={LL.FREQ()}
value={numberValue(editItem.f)}
type="number"
variant="outlined"
sx={{ width: '11ch' }}
onChange={updateFormValue}
disabled={editItem.s}
slotProps={{
input: {
startAdornment: (
@@ -294,14 +420,14 @@ const SensorsAnalogDialog = ({
/>
</Grid>
<Grid>
<TextField
<ValidatedTextField
name="o"
label={LL.DUTY_CYCLE()}
value={numberValue(editItem.o)}
type="number"
sx={{ width: '11ch' }}
variant="outlined"
onChange={updateFormValue}
disabled={editItem.s}
slotProps={{
input: {
startAdornment: (
@@ -314,13 +440,81 @@ const SensorsAnalogDialog = ({
</Grid>
</>
)}
{editItem.t === AnalogType.PULSE && (
<>
<Grid>
<ValidatedTextField
name="o"
label={LL.POLARITY()}
value={editItem.o}
sx={{ width: '11ch' }}
select
onChange={updateFormValue}
disabled={editItem.s}
>
<MenuItem value={0}>{LL.ACTIVEHIGH()}</MenuItem>
<MenuItem value={1}>{LL.ACTIVELOW()}</MenuItem>
</ValidatedTextField>
</Grid>
<Grid>
<ValidatedTextField
name="f"
label="Pulse"
value={numberValue(editItem.f)}
type="number"
sx={{ width: '15ch' }}
onChange={updateFormValue}
disabled={editItem.s}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">s</InputAdornment>
)
},
htmlInput: { min: '0', max: '10000', step: '0.1' }
}}
/>
</Grid>
</>
)}
</Grid>
{fieldErrors && Object.keys(fieldErrors).length > 0 && (
<Box mt={1}>
{Object.values(fieldErrors).map((errArr, idx) =>
Array.isArray(errArr)
? errArr.map((err, j) => (
<Typography
key={`${idx}-${j}`}
color="error"
variant="caption"
display="block"
>
{err.message}
</Typography>
))
: null
)}
</Box>
)}
{editItem.s && (
<Grid>
<Typography mt={1} color="warning.main" variant="body2">
<WarningIcon
fontSize="small"
sx={{ mr: 1, verticalAlign: 'middle' }}
color="warning"
/>
{LL.SYSTEM(0)} {LL.SENSOR(0)}
</Typography>
</Grid>
)}
</DialogContent>
<DialogActions>
{!creating && (
<Box flexGrow={1} sx={{ '& button': { mt: 0 } }}>
<Button
startIcon={<RemoveIcon />}
disabled={editItem.s}
variant="outlined"
color="warning"
onClick={remove}
@@ -338,7 +532,7 @@ const SensorsAnalogDialog = ({
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
startIcon={<DoneIcon />}
variant="outlined"
onClick={save}
color="primary"

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done';
import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
@@ -9,7 +10,7 @@ import {
DialogActions,
DialogContent,
DialogTitle,
Grid2 as Grid,
Grid,
InputAdornment,
TextField,
Typography
@@ -33,6 +34,12 @@ interface SensorsTemperatureDialogProps {
validator: Schema;
}
// Constants
const OFFSET_MIN = -5;
const OFFSET_MAX = 5;
const OFFSET_STEP = 0.1;
const TEMP_UNIT = '°C';
const SensorsTemperatureDialog = ({
open,
onClose,
@@ -43,7 +50,18 @@ const SensorsTemperatureDialog = ({
const { LL } = useI18nContext();
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [editItem, setEditItem] = useState<TemperatureSensor>(selectedItem);
const updateFormValue = updateValue(setEditItem);
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as (
updater: (
prevState: Readonly<Record<string, unknown>>
) => Record<string, unknown>
) => void
),
[setEditItem]
);
useEffect(() => {
if (open) {
@@ -52,13 +70,16 @@ const SensorsTemperatureDialog = ({
}
}, [open, selectedItem]);
const handleClose = (_event, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
};
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason?: string) => {
if (reason !== 'backdropClick') {
onClose();
}
},
[onClose]
);
const save = async () => {
const save = useCallback(async () => {
try {
setFieldErrors(undefined);
await validate(validator, editItem);
@@ -66,15 +87,31 @@ const SensorsTemperatureDialog = ({
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
};
}, [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 (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>
{LL.EDIT()}&nbsp;{LL.TEMP_SENSOR()}
</DialogTitle>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent dividers>
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
<Box color="warning.main" mb={2}>
<Typography variant="body2">
{LL.ID_OF(LL.SENSOR(0))}: {editItem.id}
</Typography>
@@ -82,7 +119,7 @@ const SensorsTemperatureDialog = ({
<Grid container spacing={2}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors ?? {}}
name="n"
label={LL.NAME(0)}
value={editItem.n}
@@ -94,22 +131,27 @@ const SensorsTemperatureDialog = ({
<TextField
name="o"
label={LL.OFFSET()}
value={numberValue(editItem.o)}
value={offsetValue}
sx={{ width: '11ch' }}
type="number"
variant="outlined"
onChange={updateFormValue}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">°C</InputAdornment>
)
},
htmlInput: { min: '-5', max: '5', step: '0.1' }
}}
slotProps={slotProps}
/>
</Grid>
</Grid>
{editItem.s && (
<Grid>
<Typography mt={1} color="warning.main" variant="body2">
<WarningIcon
fontSize="small"
sx={{ mr: 1, verticalAlign: 'middle' }}
color="warning"
/>
{LL.SYSTEM(0)} {LL.SENSOR(0)}
</Typography>
</Grid>
)}
</DialogContent>
<DialogActions>
<Button
@@ -121,7 +163,7 @@ const SensorsTemperatureDialog = ({
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
startIcon={<DoneIcon />}
variant="outlined"
onClick={save}
color="primary"

View File

@@ -0,0 +1,65 @@
import { memo, useCallback, useContext } from 'react';
import PersonIcon from '@mui/icons-material/Person';
import {
Avatar,
Box,
Button,
Divider,
List,
ListItem,
ListItemText,
Typography
} from '@mui/material';
import { AuthenticatedContext } from '@/contexts/authentication';
import { SectionContent, useLayoutTitle } from 'components';
import { LanguageSelector } from 'components/inputs';
import { useI18nContext } from 'i18n/i18n-react';
const UserProfileComponent = () => {
const { LL } = useI18nContext();
const { me, signOut } = useContext(AuthenticatedContext);
useLayoutTitle(LL.USER_PROFILE());
const handleSignOut = useCallback(() => {
signOut(true);
}, [signOut]);
return (
<SectionContent>
<List sx={{ flexGrow: 1 }}>
<ListItem disablePadding>
<Avatar sx={{ bgcolor: '#9e9e9e', color: 'white' }}>
<PersonIcon />
</Avatar>
<ListItemText
sx={{ pl: 2, color: '#2196f3' }}
primary={me.username}
secondary={'(' + (me.admin ? LL.ADMINISTRATOR() : LL.GUEST()) + ')'}
/>
</ListItem>
</List>
<Box mt={2} mb={2} display="flex" alignItems="center">
<Typography mr={2} variant="body1" align="center">
{LL.LANGUAGE()}:
</Typography>
<LanguageSelector />
</Box>
<Divider />
<Button
sx={{ mt: 2 }}
variant="outlined"
color="primary"
onClick={handleSignOut}
>
{LL.SIGN_OUT()}
</Button>
</SectionContent>
);
};
const UserProfile = memo(UserProfileComponent);
export default UserProfile;

View File

@@ -2,27 +2,30 @@ import type { TranslationFunctions } from 'i18n/i18n-types';
import { DeviceValueUOM, DeviceValueUOM_s } from './types';
// Cache NumberFormat instances for better performance
const numberFormatter = new Intl.NumberFormat();
const numberFormatterWithDecimal = new Intl.NumberFormat(undefined, {
minimumFractionDigits: 1
});
const formatDurationMin = (LL: TranslationFunctions, duration_min: number) => {
const days = Math.trunc((duration_min * 60000) / 86400000);
const hours = Math.trunc((duration_min * 60000) / 3600000) % 24;
const minutes = Math.trunc((duration_min * 60000) / 60000) % 60;
const totalMs = duration_min * 60000;
const days = Math.trunc(totalMs / 86400000);
const hours = Math.trunc(totalMs / 3600000) % 24;
const minutes = Math.trunc(duration_min) % 60;
let formatted = '';
const parts: string[] = [];
if (days) {
formatted += LL.NUM_DAYS({ num: days });
parts.push(LL.NUM_DAYS({ num: days }));
}
if (hours) {
if (formatted) formatted += ' ';
formatted += LL.NUM_HOURS({ num: hours });
parts.push(LL.NUM_HOURS({ num: hours }));
}
if (minutes) {
if (formatted) formatted += ' ';
formatted += LL.NUM_MINUTES({ num: minutes });
parts.push(LL.NUM_MINUTES({ num: minutes }));
}
return formatted;
return parts.join(' ');
};
export function formatValue(
@@ -30,18 +33,21 @@ export function formatValue(
value?: unknown,
uom?: DeviceValueUOM
) {
// Handle non-numeric values or missing data
if (typeof value !== 'number' || uom === undefined || value === undefined) {
if (value === undefined || typeof value === 'boolean') {
return '';
}
// Type assertion is safe here since we know it's not a number, boolean, or undefined
return (
(value as string) +
(value === '' || uom === undefined || uom === 0
(value === '' || uom === undefined || uom === DeviceValueUOM.NONE
? ''
: ' ' + DeviceValueUOM_s[uom])
);
}
// Handle numeric values
switch (uom) {
case DeviceValueUOM.HOURS:
return value ? formatDurationMin(LL, value * 60) : LL.NUM_HOURS({ num: 0 });
@@ -50,18 +56,12 @@ export function formatValue(
case DeviceValueUOM.SECONDS:
return LL.NUM_SECONDS({ num: value });
case DeviceValueUOM.NONE:
return new Intl.NumberFormat().format(value);
return numberFormatter.format(value);
case DeviceValueUOM.DEGREES:
case DeviceValueUOM.DEGREES_R:
case DeviceValueUOM.FAHRENHEIT:
return (
new Intl.NumberFormat(undefined, {
minimumFractionDigits: 1
}).format(value) +
' ' +
DeviceValueUOM_s[uom]
);
return numberFormatterWithDecimal.format(value) + ' ' + DeviceValueUOM_s[uom];
default:
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
return numberFormatter.format(value) + ' ' + DeviceValueUOM_s[uom];
}
}

View File

@@ -60,7 +60,7 @@ export interface Stat {
}
export interface Activity {
stats: Stat[];
readonly stats: readonly Stat[];
}
export interface Device {
@@ -82,38 +82,43 @@ export interface TemperatureSensor {
t?: number; // temp, optional
o: number; // offset
u: number; // uom
s: boolean; // system sensor flag
o_n?: string;
}
export interface AnalogSensor {
id: number;
g: number; // GPIO
n: string;
v: number;
u: number;
o: number;
f: number;
t: number;
n: string; // name
v: number; // value
u: number; // uom
o: number; // offset
f: number; // factor
t: number; // type
d: boolean; // deleted flag
o_n?: string;
s: boolean; // system sensor flag
o_n?: string; // original name
}
export interface WriteTemperatureSensor {
id: string;
name: string;
offset: number;
is_system: boolean;
}
export interface SensorData {
ts: TemperatureSensor[];
as: AnalogSensor[];
analog_enabled: boolean;
available_gpios: number[];
exclude_types: number[];
platform: string;
}
export interface CoreData {
connected: boolean;
devices: Device[];
readonly connected: boolean;
readonly devices: readonly Device[];
}
export interface DashboardItem {
@@ -122,11 +127,12 @@ export interface DashboardItem {
n?: string; // name, optional
dv?: DeviceValue; // device value, optional
nodes?: DashboardItem[]; // children nodes, optional
parentNode: DashboardItem; // to stop lint errors
}
export interface DashboardData {
connected: boolean; // true if connected to EMS bus
nodes: DashboardItem[];
readonly connected: boolean; // true if connected to EMS bus
readonly nodes: readonly DashboardItem[];
}
export interface DeviceValue {
@@ -139,10 +145,11 @@ export interface DeviceValue {
s?: string; // steps for up/down, optional
m?: number; // min, optional
x?: number; // max, optional
[key: string]: unknown;
}
export interface DeviceData {
nodes: DeviceValue[];
readonly nodes: readonly DeviceValue[];
}
export interface DeviceEntity {
@@ -188,13 +195,14 @@ export enum DeviceValueUOM {
VOLTS,
MBAR,
LH,
CTKWH
CTKWH,
HZ
}
export const DeviceValueUOM_s = [
'',
'°C',
'°C',
'°C Rel',
'%',
'l/min',
'kWh',
@@ -218,12 +226,12 @@ export const DeviceValueUOM_s = [
'V',
'mbar',
'l/h',
'ct/kWh'
];
'ct/kWh',
'Hz'
] as const;
export enum AnalogType {
REMOVED = -1,
NOTUSED = 0,
DIGITAL_IN = 1,
COUNTER = 2,
ADC = 3,
@@ -232,29 +240,45 @@ export enum AnalogType {
DIGITAL_OUT = 6,
PWM_0 = 7,
PWM_1 = 8,
PWM_2 = 9
PWM_2 = 9,
NTC = 10,
RGB = 11,
PULSE = 12,
FREQ_0 = 13,
FREQ_1 = 14,
FREQ_2 = 15,
CNT_0 = 16,
CNT_1 = 17,
CNT_2 = 18
}
export const AnalogTypeNames = [
'(disabled)',
'Digital In',
'Counter',
'ADC',
'Timer',
'Rate',
'Digital Out',
'PWM 0',
'PWM 1',
'PWM 2'
];
'Digital In', // 1
'Counter', // 2
'ADC In', // 3
'Timer', // 4
'Rate', // 5
'Digital Out', // 6
'PWM 0', // 7
'PWM 1', // 8
'PWM 2', // 9
'NTC Temp', // 10
'RGB Led', // 11
'Pulse', // 12
'Freq 0', // 13
'Freq 1', // 14
'Freq 2', // 15
'Counter 0', // 16
'Counter 1', // 17
'Counter 2' // 18
] as const;
type BoardProfiles = Record<string, string>;
export const BOARD_PROFILES: BoardProfiles = {
export const BOARD_PROFILES = {
S32: 'BBQKees Gateway S32',
S32S3: 'BBQKees Gateway S3',
E32: 'BBQKees Gateway E32',
E32V2: 'BBQKees Gateway E32 V2',
E32V2_2: 'BBQKees Gateway E32 V2.2',
NODEMCU: 'NodeMCU 32S',
'MH-ET': 'MH-ET Live D1 Mini',
LOLIN: 'Lolin D32',
@@ -263,7 +287,9 @@ export const BOARD_PROFILES: BoardProfiles = {
C3MINI: 'Wemos C3 Mini',
S2MINI: 'Wemos S2 Mini',
S3MINI: 'Liligo S3'
};
} as const;
export type BoardProfileKey = keyof typeof BOARD_PROFILES;
export interface BoardProfile {
board_profile: string;
@@ -300,6 +326,7 @@ export interface WriteAnalogSensor {
uom: number;
type: number;
deleted: boolean;
is_system: boolean;
}
export enum DeviceEntityMask {
@@ -331,7 +358,7 @@ export interface ScheduleItem {
}
export interface Schedule {
schedule: ScheduleItem[];
readonly schedule: readonly ScheduleItem[];
}
export interface ModuleItem {
@@ -349,7 +376,7 @@ export interface ModuleItem {
}
export interface Modules {
modules: ModuleItem[];
readonly modules: readonly ModuleItem[];
}
export enum ScheduleFlag {
@@ -380,6 +407,7 @@ export interface EntityItem {
value_type: number;
value?: unknown;
writeable: boolean;
hide: boolean;
deleted?: boolean;
o_id?: number;
o_ram?: number;
@@ -393,10 +421,11 @@ export interface EntityItem {
o_deleted?: boolean;
o_writeable?: boolean;
o_value?: unknown;
o_hide?: boolean;
}
export interface Entities {
entities: EntityItem[];
readonly entities: readonly EntityItem[];
}
// matches emsdevice.h DeviceType
@@ -452,4 +481,4 @@ export const DeviceValueTypeNames = [
'ENUM',
'RAW',
'CMD'
];
] as const;

View File

@@ -11,376 +11,273 @@ import type {
TemperatureSensor
} from './types';
export const GPIO_VALIDATOR = {
validator(
rule: InternalRuleItem,
value: number,
callback: (error?: string) => void
) {
if (
value &&
(value === 1 ||
(value >= 6 && value <= 11) ||
value === 20 ||
value === 24 ||
(value >= 28 && value <= 31) ||
value > 40 ||
value < 0)
) {
callback('Must be an valid GPIO port');
} else {
callback();
}
// Constants
const ERROR_MESSAGES = {
GPIO_INVALID: 'Must be an valid GPIO port',
NAME_DUPLICATE: 'Name already in use',
GPIO_DUPLICATE: 'GPIO already in use',
VALUE_OUT_OF_RANGE: 'Value out of range',
HEX_REQUIRED: 'Is required and must be in hex format'
} as const;
const VALIDATION_LIMITS = {
PORT_MIN: 0,
PORT_MAX: 65535,
MODBUS_MAX_CLIENTS_MIN: 0,
MODBUS_MAX_CLIENTS_MAX: 50,
MODBUS_TIMEOUT_MIN: 100,
MODBUS_TIMEOUT_MAX: 20000,
SYSLOG_MARK_INTERVAL_MIN: 0,
SYSLOG_MARK_INTERVAL_MAX: 3600,
SHOWER_MIN_DURATION_MIN: 10,
SHOWER_MIN_DURATION_MAX: 360,
SHOWER_ALERT_TRIGGER_MIN: 1,
SHOWER_ALERT_TRIGGER_MAX: 20,
SHOWER_ALERT_COLDSHOT_MIN: 1,
SHOWER_ALERT_COLDSHOT_MAX: 10,
REMOTE_TIMEOUT_MIN: 1,
REMOTE_TIMEOUT_MAX: 240,
OFFSET_MIN: 0,
OFFSET_MAX: 255,
COMMAND_MIN: 1,
COMMAND_MAX: 300,
NAME_MAX_LENGTH: 19,
HEX_BASE: 16
} as const;
type ValidationRules = Array<{
required?: boolean;
message?: string;
[key: string]: unknown;
}>;
export const createSettingsValidator = (settings: Settings) => {
const schema: Record<string, ValidationRules> = {};
// Syslog validations
if (settings.syslog_enabled) {
schema.syslog_host = [
{ required: true, message: 'Host is required' },
IP_OR_HOSTNAME_VALIDATOR
];
schema.syslog_port = [
{ required: true, message: 'Port is required' },
{
type: 'number',
min: VALIDATION_LIMITS.PORT_MIN,
max: VALIDATION_LIMITS.PORT_MAX,
message: 'Invalid Port'
}
];
schema.syslog_mark_interval = [
{ required: true, message: 'Mark interval is required' },
{
type: 'number',
min: VALIDATION_LIMITS.SYSLOG_MARK_INTERVAL_MIN,
max: VALIDATION_LIMITS.SYSLOG_MARK_INTERVAL_MAX,
message: `Must be between ${VALIDATION_LIMITS.SYSLOG_MARK_INTERVAL_MIN} and ${VALIDATION_LIMITS.SYSLOG_MARK_INTERVAL_MAX}`
}
];
}
// Modbus validations
if (settings.modbus_enabled) {
schema.modbus_max_clients = [
{ required: true, message: 'Max clients is required' },
{
type: 'number',
min: VALIDATION_LIMITS.MODBUS_MAX_CLIENTS_MIN,
max: VALIDATION_LIMITS.MODBUS_MAX_CLIENTS_MAX,
message: 'Invalid number'
}
];
schema.modbus_port = [
{ required: true, message: 'Port is required' },
{
type: 'number',
min: VALIDATION_LIMITS.PORT_MIN,
max: VALIDATION_LIMITS.PORT_MAX,
message: 'Invalid Port'
}
];
schema.modbus_timeout = [
{ required: true, message: 'Timeout is required' },
{
type: 'number',
min: VALIDATION_LIMITS.MODBUS_TIMEOUT_MIN,
max: VALIDATION_LIMITS.MODBUS_TIMEOUT_MAX,
message: `Must be between ${VALIDATION_LIMITS.MODBUS_TIMEOUT_MIN} and ${VALIDATION_LIMITS.MODBUS_TIMEOUT_MAX}`
}
];
}
// Shower timer validations
if (settings.shower_timer) {
schema.shower_min_duration = [
{
type: 'number',
min: VALIDATION_LIMITS.SHOWER_MIN_DURATION_MIN,
max: VALIDATION_LIMITS.SHOWER_MIN_DURATION_MAX,
message: `Time must be between ${VALIDATION_LIMITS.SHOWER_MIN_DURATION_MIN} and ${VALIDATION_LIMITS.SHOWER_MIN_DURATION_MAX} seconds`
}
];
}
// Shower alert validations
if (settings.shower_alert) {
schema.shower_alert_trigger = [
{
type: 'number',
min: VALIDATION_LIMITS.SHOWER_ALERT_TRIGGER_MIN,
max: VALIDATION_LIMITS.SHOWER_ALERT_TRIGGER_MAX,
message: `Time must be between ${VALIDATION_LIMITS.SHOWER_ALERT_TRIGGER_MIN} and ${VALIDATION_LIMITS.SHOWER_ALERT_TRIGGER_MAX} minutes`
}
];
schema.shower_alert_coldshot = [
{
type: 'number',
min: VALIDATION_LIMITS.SHOWER_ALERT_COLDSHOT_MIN,
max: VALIDATION_LIMITS.SHOWER_ALERT_COLDSHOT_MAX,
message: `Time must be between ${VALIDATION_LIMITS.SHOWER_ALERT_COLDSHOT_MIN} and ${VALIDATION_LIMITS.SHOWER_ALERT_COLDSHOT_MAX} seconds`
}
];
}
// Remote timeout validations
if (settings.remote_timeout_en) {
schema.remote_timeout = [
{
type: 'number',
min: VALIDATION_LIMITS.REMOTE_TIMEOUT_MIN,
max: VALIDATION_LIMITS.REMOTE_TIMEOUT_MAX,
message: `Timeout must be between ${VALIDATION_LIMITS.REMOTE_TIMEOUT_MIN} and ${VALIDATION_LIMITS.REMOTE_TIMEOUT_MAX} hours`
}
];
}
return new Schema(schema);
};
export const GPIO_VALIDATORR = {
// Generic unique name validator factory
const createUniqueNameValidator = <T extends { name: string }>(
items: T[],
originalName?: string
) => ({
validator(
rule: InternalRuleItem,
value: number,
callback: (error?: string) => void
) {
if (
value &&
(value === 1 ||
(value >= 6 && value <= 11) ||
(value >= 16 && value <= 17) ||
value === 20 ||
value === 24 ||
(value >= 28 && value <= 31) ||
value > 40 ||
value < 0)
) {
callback('Must be an valid GPIO port');
} else {
callback();
}
}
};
export const GPIO_VALIDATORC3 = {
validator(
rule: InternalRuleItem,
value: number,
callback: (error?: string) => void
) {
if (value && ((value >= 11 && value <= 19) || value > 21 || value < 0)) {
callback('Must be an valid GPIO port');
} else {
callback();
}
}
};
export const GPIO_VALIDATORS2 = {
validator(
rule: InternalRuleItem,
value: number,
callback: (error?: string) => void
) {
if (
value &&
((value >= 19 && value <= 20) ||
(value >= 22 && value <= 32) ||
value > 40 ||
value < 0)
) {
callback('Must be an valid GPIO port');
} else {
callback();
}
}
};
export const GPIO_VALIDATORS3 = {
validator(
rule: InternalRuleItem,
value: number,
callback: (error?: string) => void
) {
if (
value &&
((value >= 19 && value <= 20) ||
(value >= 22 && value <= 37) ||
(value >= 39 && value <= 42) ||
value > 48 ||
value < 0)
) {
callback('Must be an valid GPIO port');
} else {
callback();
}
}
};
export const createSettingsValidator = (settings: Settings) =>
new Schema({
...(settings.board_profile === 'CUSTOM' &&
settings.platform === 'ESP32' && {
led_gpio: [
{ required: true, message: 'LED GPIO is required' },
GPIO_VALIDATOR
],
dallas_gpio: [
{ required: true, message: 'GPIO is required' },
GPIO_VALIDATOR
],
pbutton_gpio: [
{ required: true, message: 'Button GPIO is required' },
GPIO_VALIDATOR
],
tx_gpio: [
{ required: true, message: 'Tx GPIO is required' },
GPIO_VALIDATOR
],
rx_gpio: [{ required: true, message: 'Rx GPIO is required' }, GPIO_VALIDATOR]
}),
...(settings.board_profile === 'CUSTOM' &&
settings.platform === 'ESP32C3' && {
led_gpio: [
{ required: true, message: 'LED GPIO is required' },
GPIO_VALIDATORC3
],
dallas_gpio: [
{ required: true, message: 'GPIO is required' },
GPIO_VALIDATORC3
],
pbutton_gpio: [
{ required: true, message: 'Button GPIO is required' },
GPIO_VALIDATORC3
],
tx_gpio: [
{ required: true, message: 'Tx GPIO is required' },
GPIO_VALIDATORC3
],
rx_gpio: [
{ required: true, message: 'Rx GPIO is required' },
GPIO_VALIDATORC3
]
}),
...(settings.board_profile === 'CUSTOM' &&
settings.platform === 'ESP32S2' && {
led_gpio: [
{ required: true, message: 'LED GPIO is required' },
GPIO_VALIDATORS2
],
dallas_gpio: [
{ required: true, message: 'GPIO is required' },
GPIO_VALIDATORS2
],
pbutton_gpio: [
{ required: true, message: 'Button GPIO is required' },
GPIO_VALIDATORS2
],
tx_gpio: [
{ required: true, message: 'Tx GPIO is required' },
GPIO_VALIDATORS2
],
rx_gpio: [
{ required: true, message: 'Rx GPIO is required' },
GPIO_VALIDATORS2
]
}),
...(settings.board_profile === 'CUSTOM' &&
settings.platform === 'ESP32S3' && {
led_gpio: [
{ required: true, message: 'LED GPIO is required' },
GPIO_VALIDATORS3
],
dallas_gpio: [
{ required: true, message: 'GPIO is required' },
GPIO_VALIDATORS3
],
pbutton_gpio: [
{ required: true, message: 'Button GPIO is required' },
GPIO_VALIDATORS3
],
tx_gpio: [
{ required: true, message: 'Tx GPIO is required' },
GPIO_VALIDATORS3
],
rx_gpio: [
{ required: true, message: 'Rx GPIO is required' },
GPIO_VALIDATORS3
]
}),
...(settings.syslog_enabled && {
syslog_host: [
{ required: true, message: 'Host is required' },
IP_OR_HOSTNAME_VALIDATOR
],
syslog_port: [
{ required: true, message: 'Port is required' },
{ type: 'number', min: 0, max: 65535, message: 'Invalid Port' }
],
syslog_mark_interval: [
{ required: true, message: 'Mark interval is required' },
{ type: 'number', min: 0, max: 10, message: 'Must be between 0 and 10' }
]
}),
...(settings.modbus_enabled && {
modbus_max_clients: [
{ required: true, message: 'Max clients is required' },
{ type: 'number', min: 0, max: 50, message: 'Invalid number' }
],
modbus_port: [
{ required: true, message: 'Port is required' },
{ type: 'number', min: 0, max: 65535, message: 'Invalid Port' }
],
modbus_timeout: [
{ required: true, message: 'Timeout is required' },
{
type: 'number',
min: 100,
max: 20000,
message: 'Must be between 100 and 20000'
}
]
}),
...(settings.shower_timer && {
shower_min_duration: [
{
type: 'number',
min: 10,
max: 360,
message: 'Time must be between 10 and 360 seconds'
}
]
}),
...(settings.shower_alert && {
shower_alert_trigger: [
{
type: 'number',
min: 1,
max: 20,
message: 'Time must be between 1 and 20 minutes'
}
],
shower_alert_coldshot: [
{
type: 'number',
min: 1,
max: 10,
message: 'Time must be between 1 and 10 seconds'
}
]
}),
...(settings.remote_timeout_en && {
remote_timeout: [
{
type: 'number',
min: 1,
max: 240,
message: 'Timeout must be between 1 and 240 hours'
}
]
})
});
export const uniqueNameValidator = (schedule: ScheduleItem[], o_name?: string) => ({
validator(
rule: InternalRuleItem,
_rule: InternalRuleItem,
name: string,
callback: (error?: string) => void
) {
if (
name !== '' &&
(o_name === undefined || o_name.toLowerCase() !== name.toLowerCase()) &&
schedule.find((si) => si.name.toLowerCase() === name.toLowerCase())
(originalName === undefined ||
originalName.toLowerCase() !== name.toLowerCase()) &&
items.find((item) => item.name.toLowerCase() === name.toLowerCase())
) {
callback('Name already in use');
} else {
callback();
callback(ERROR_MESSAGES.NAME_DUPLICATE);
return;
}
callback();
}
});
// Generic field name validator (for cases where the name field has different property names)
const createUniqueFieldNameValidator = <T>(
items: T[],
getName: (item: T) => string,
originalName?: string
) => ({
validator(
_rule: InternalRuleItem,
name: string,
callback: (error?: string) => void
) {
if (
name !== '' &&
(originalName === undefined ||
originalName.toLowerCase() !== name.toLowerCase()) &&
items.find((item) => getName(item).toLowerCase() === name.toLowerCase())
) {
callback(ERROR_MESSAGES.NAME_DUPLICATE);
return;
}
callback();
}
});
const NAME_PATTERN_BASE = '[a-zA-Z0-9_]';
const NAME_PATTERN_MESSAGE = `Must be <${VALIDATION_LIMITS.NAME_MAX_LENGTH + 1} characters: alphanumeric or '_'`;
const NAME_PATTERN = {
type: 'string' as const,
pattern: new RegExp(
`^${NAME_PATTERN_BASE}{0,${VALIDATION_LIMITS.NAME_MAX_LENGTH}}$`
),
message: NAME_PATTERN_MESSAGE
};
const NAME_PATTERN_REQUIRED = {
type: 'string' as const,
pattern: new RegExp(
`^${NAME_PATTERN_BASE}{1,${VALIDATION_LIMITS.NAME_MAX_LENGTH}}$`
),
message: NAME_PATTERN_MESSAGE
};
export const uniqueNameValidator = (schedule: ScheduleItem[], o_name?: string) =>
createUniqueNameValidator(schedule, o_name);
export const schedulerItemValidation = (
schedule: ScheduleItem[],
scheduleItem: ScheduleItem
) =>
new Schema({
name: [
{
type: 'string',
pattern: /^[a-zA-Z0-9_]{0,19}$/,
message: "Must be <20 characters: alphanumeric or '_'"
},
...[uniqueNameValidator(schedule, scheduleItem.o_name)]
],
name: [NAME_PATTERN, uniqueNameValidator(schedule, scheduleItem.o_name)],
cmd: [
{ required: true, message: 'Command is required' },
{
type: 'string',
min: 1,
max: 300,
message: 'Command must be 1-300 characters'
min: VALIDATION_LIMITS.COMMAND_MIN,
max: VALIDATION_LIMITS.COMMAND_MAX,
message: `Command must be ${VALIDATION_LIMITS.COMMAND_MIN}-${VALIDATION_LIMITS.COMMAND_MAX} characters`
}
]
});
export const uniqueCustomNameValidator = (
entity: EntityItem[],
o_name?: string
) => ({
export const uniqueCustomNameValidator = (entity: EntityItem[], o_name?: string) =>
createUniqueNameValidator(entity, o_name);
const hexValidator = {
validator(
rule: InternalRuleItem,
name: string,
_rule: InternalRuleItem,
value: string,
callback: (error?: string) => void
) {
if (
(o_name === undefined || o_name.toLowerCase() !== name.toLowerCase()) &&
entity.find((ei) => ei.name.toLowerCase() === name.toLowerCase())
) {
callback('Name already in use');
} else {
callback();
if (!value || Number.isNaN(Number.parseInt(value, VALIDATION_LIMITS.HEX_BASE))) {
callback(ERROR_MESSAGES.HEX_REQUIRED);
return;
}
callback();
}
});
};
export const entityItemValidation = (entity: EntityItem[], entityItem: EntityItem) =>
new Schema({
name: [
{ required: true, message: 'Name is required' },
{
type: 'string',
pattern: /^[a-zA-Z0-9_]{1,19}$/,
message: "Must be <20 characters: alphanumeric or '_'"
},
...[uniqueCustomNameValidator(entity, entityItem.o_name)]
],
device_id: [
{
validator(
rule: InternalRuleItem,
value: string,
callback: (error?: string) => void
) {
if (isNaN(parseInt(value, 16))) {
callback('Is required and must be in hex format');
}
callback();
}
}
],
type_id: [
{
validator(
rule: InternalRuleItem,
value: string,
callback: (error?: string) => void
) {
if (isNaN(parseInt(value, 16))) {
callback('Is required and must be in hex format');
}
callback();
}
}
NAME_PATTERN_REQUIRED,
uniqueCustomNameValidator(entity, entityItem.o_name)
],
device_id: [hexValidator],
type_id: [hexValidator],
offset: [
{ required: true, message: 'Offset is required' },
{ type: 'number', min: 0, max: 255, message: 'Must be between 0 and 255' }
{
type: 'number',
min: VALIDATION_LIMITS.OFFSET_MIN,
max: VALIDATION_LIMITS.OFFSET_MAX,
message: `Must be between ${VALIDATION_LIMITS.OFFSET_MIN} and ${VALIDATION_LIMITS.OFFSET_MAX}`
}
],
factor: [{ required: true, message: 'is required' }]
});
@@ -388,93 +285,34 @@ export const entityItemValidation = (entity: EntityItem[], entityItem: EntityIte
export const uniqueTemperatureNameValidator = (
sensors: TemperatureSensor[],
o_name?: string
) => ({
validator(rule: InternalRuleItem, n: string, callback: (error?: string) => void) {
if (
(o_name === undefined || o_name.toLowerCase() !== n.toLowerCase()) &&
n !== '' &&
sensors.find((ts) => ts.n.toLowerCase() === n.toLowerCase())
) {
callback('Name already in use');
} else {
callback();
}
}
});
) => createUniqueFieldNameValidator(sensors, (s) => s.n, o_name);
export const temperatureSensorItemValidation = (
sensors: TemperatureSensor[],
sensor: TemperatureSensor
) =>
new Schema({
n: [
{
type: 'string',
pattern: /^[a-zA-Z0-9_]{0,19}$/,
message: "Must be <20 characters: alphanumeric or '_'"
},
...[uniqueTemperatureNameValidator(sensors, sensor.o_n)]
]
n: [NAME_PATTERN, uniqueTemperatureNameValidator(sensors, sensor.o_n)]
});
export const isGPIOUniqueValidator = (sensors: AnalogSensor[]) => ({
validator(
rule: InternalRuleItem,
gpio: number,
callback: (error?: string) => void
) {
if (sensors.find((as) => as.g === gpio)) {
callback('GPIO already in use');
} else {
callback();
}
}
});
export const uniqueAnalogNameValidator = (
sensors: AnalogSensor[],
o_name?: string
) => ({
validator(rule: InternalRuleItem, n: string, callback: (error?: string) => void) {
if (
(o_name === undefined || o_name.toLowerCase() !== n.toLowerCase()) &&
n !== '' &&
sensors.find((as) => as.n.toLowerCase() === n.toLowerCase())
) {
callback('Name already in use');
} else {
callback();
}
}
});
) => createUniqueFieldNameValidator(sensors, (s) => s.n, o_name);
export const analogSensorItemValidation = (
sensors: AnalogSensor[],
sensor: AnalogSensor,
creating: boolean,
platform: string
) =>
new Schema({
sensor: AnalogSensor
) => {
return new Schema({
// name is required and must be unique
n: [
{
type: 'string',
pattern: /^[a-zA-Z0-9_]{0,19}$/,
message: "Must be <20 characters: alphanumeric or '_'"
},
...[uniqueAnalogNameValidator(sensors, sensor.o_n)]
],
g: [
{ required: true, message: 'GPIO is required' },
platform === 'ESP32S3'
? GPIO_VALIDATORS3
: platform === 'ESP32S2'
? GPIO_VALIDATORS2
: platform === 'ESP32C3'
? GPIO_VALIDATORC3
: GPIO_VALIDATOR,
...(creating ? [isGPIOUniqueValidator(sensors)] : [])
{ required: true, message: 'Name is required' },
NAME_PATTERN,
uniqueAnalogNameValidator(sensors, sensor.o_n)
]
});
};
export const deviceValueItemValidation = (dv: DeviceValue) =>
new Schema({
@@ -482,17 +320,18 @@ export const deviceValueItemValidation = (dv: DeviceValue) =>
{ required: true, message: 'Value is required' },
{
validator(
rule: InternalRuleItem,
_rule: InternalRuleItem,
value: unknown,
callback: (error?: string) => void
) {
if (
typeof value === 'number' &&
dv.m &&
dv.x &&
dv.m !== undefined &&
dv.x !== undefined &&
(value < dv.m || value > dv.x)
) {
callback('Value out of range');
callback(ERROR_MESSAGES.VALUE_OUT_OF_RANGE);
return;
}
callback();
}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning';
@@ -27,6 +27,19 @@ export const isAPEnabled = ({ provision_mode }: APSettingsType) =>
provision_mode === APProvisionMode.AP_MODE_ALWAYS ||
provision_mode === APProvisionMode.AP_MODE_DISCONNECTED;
// Efficient range function without recursion
const createRange = (start: number, end: number): number[] => {
const result: number[] = [];
for (let i = start; i <= end; i++) {
result.push(i);
}
return result;
};
// Pre-computed ranges for better performance
const CHANNEL_RANGE = createRange(1, 14);
const MAX_CLIENTS_RANGE = createRange(1, 9);
const APSettings = () => {
const {
loadData,
@@ -50,37 +63,42 @@ const APSettings = () => {
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValueDirty(
origData,
dirtyFlags,
setDirtyFlags,
updateDataValue
const updateFormValue = useMemo(
() =>
updateValueDirty(
origData as unknown as Record<string, unknown>,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
);
// Memoize AP enabled state
const apEnabled = useMemo(() => (data ? isAPEnabled(data) : false), [data]);
// Memoize validation and submit handler
const validateAndSubmit = useCallback(async () => {
if (!data) return;
try {
setFieldErrors(undefined);
await validate(createAPSettingsValidator(data), data);
await saveData();
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
}, [data, saveData]);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(createAPSettingsValidator(data), data);
await saveData();
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
};
// no lodash - https://asleepace.com/blog/typescript-range-without-a-loop/
function range(a: number, b: number): number[] {
return a < b ? [a, ...range(a + 1, b)] : [b];
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
return (
<>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="provision_mode"
label={LL.AP_PROVIDE() + '...'}
value={data.provision_mode}
@@ -100,10 +118,10 @@ const APSettings = () => {
{LL.AP_PROVIDE_TEXT_3()}
</MenuItem>
</ValidatedTextField>
{isAPEnabled(data) && (
{apEnabled && (
<>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="ssid"
label={LL.ACCESS_POINT(2) + ' SSID'}
fullWidth
@@ -113,7 +131,7 @@ const APSettings = () => {
margin="normal"
/>
<ValidatedPasswordField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="password"
label={LL.ACCESS_POINT(2) + ' ' + LL.PASSWORD()}
fullWidth
@@ -123,7 +141,7 @@ const APSettings = () => {
margin="normal"
/>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="channel"
label={LL.AP_PREFERRED_CHANNEL()}
value={numberValue(data.channel)}
@@ -134,7 +152,7 @@ const APSettings = () => {
onChange={updateFormValue}
margin="normal"
>
{range(1, 14).map((i) => (
{CHANNEL_RANGE.map((i) => (
<MenuItem key={i} value={i}>
{i}
</MenuItem>
@@ -151,7 +169,7 @@ const APSettings = () => {
label={LL.AP_HIDE_SSID()}
/>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="max_clients"
label={LL.AP_MAX_CLIENTS()}
value={numberValue(data.max_clients)}
@@ -162,14 +180,14 @@ const APSettings = () => {
onChange={updateFormValue}
margin="normal"
>
{range(1, 9).map((i) => (
{MAX_CLIENTS_RANGE.map((i) => (
<MenuItem key={i} value={i}>
{i}
</MenuItem>
))}
</ValidatedTextField>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="local_ip"
label={LL.AP_LOCAL_IP()}
fullWidth
@@ -179,7 +197,7 @@ const APSettings = () => {
margin="normal"
/>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="gateway_ip"
label={LL.NETWORK_GATEWAY()}
fullWidth
@@ -189,7 +207,7 @@ const APSettings = () => {
margin="normal"
/>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="subnet_mask"
label={LL.NETWORK_SUBNET()}
fullWidth

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -9,7 +9,7 @@ import {
Button,
Checkbox,
Divider,
Grid2 as Grid,
Grid,
InputAdornment,
MenuItem,
TextField,
@@ -37,13 +37,13 @@ import { validate } from 'validators';
import { API, getBoardProfile, readSettings, writeSettings } from '../../api/app';
import { BOARD_PROFILES } from '../main/types';
import type { APIcall, Settings } from '../main/types';
import type { APIcall, BoardProfileKey, Settings } from '../main/types';
import { createSettingsValidator } from '../main/validators';
export function boardProfileSelectItems() {
return Object.keys(BOARD_PROFILES).map((code) => (
<MenuItem key={code} value={code}>
{BOARD_PROFILES[code]}
{BOARD_PROFILES[code as BoardProfileKey]}
</MenuItem>
));
}
@@ -72,10 +72,10 @@ const ApplicationSettings = () => {
const { LL } = useI18nContext();
const updateFormValue = updateValueDirty(
origData,
origData as unknown as Record<string, unknown>,
dirtyFlags,
setDirtyFlags,
updateDataValue
updateDataValue as (value: unknown) => void
);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
@@ -106,50 +106,61 @@ const ApplicationSettings = () => {
});
});
const doRestart = async () => {
// Memoized input props to prevent recreation on every render
const SecondsInputProps = useMemo(
() => ({
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}),
[LL]
);
const MinutesInputProps = useMemo(
() => ({
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
}),
[LL]
);
const HoursInputProps = useMemo(
() => ({
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
}),
[LL]
);
const doRestart = useCallback(async () => {
setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
};
}, [sendAPI]);
const updateBoardProfile = async (board_profile: string) => {
await readBoardProfile(board_profile).catch((error: Error) => {
toast.error(error.message);
});
};
const updateBoardProfile = useCallback(
async (board_profile: string) => {
await readBoardProfile(board_profile).catch((error: Error) => {
toast.error(error.message);
});
},
[readBoardProfile]
);
useLayoutTitle(LL.APPLICATION());
const SecondsInputProps = {
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
};
const MinutesInputProps = {
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
};
const HoursInputProps = {
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
};
const content = () => {
if (!data || !hardwareData) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
const validateAndSubmit = useCallback(async () => {
try {
setFieldErrors(undefined);
await validate(createSettingsValidator(data), data);
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
} finally {
await saveData();
}
}, [data, saveData]);
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(createSettingsValidator(data), data);
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
} finally {
await saveData();
}
};
const changeBoardProfile = (event: React.ChangeEvent<HTMLInputElement>) => {
const changeBoardProfile = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const boardProfile = event.target.value;
updateFormValue(event);
if (boardProfile === 'CUSTOM') {
@@ -160,12 +171,22 @@ const ApplicationSettings = () => {
} else {
void updateBoardProfile(boardProfile);
}
};
},
[data, updateBoardProfile, updateFormValue, updateDataValue]
);
const restart = async () => {
await validateAndSubmit();
await doRestart();
};
const restart = useCallback(async () => {
await validateAndSubmit();
await doRestart();
}, [validateAndSubmit, doRestart]);
// Memoize board profile select items to prevent recreation
const boardProfileItems = useMemo(() => boardProfileSelectItems(), []);
const content = () => {
if (!data || !hardwareData) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
return (
<>
@@ -219,7 +240,7 @@ const ApplicationSettings = () => {
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="modbus_max_clients"
label={LL.AP_MAX_CLIENTS()}
variant="outlined"
@@ -231,7 +252,7 @@ const ApplicationSettings = () => {
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="modbus_port"
label="Port"
variant="outlined"
@@ -243,7 +264,7 @@ const ApplicationSettings = () => {
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="modbus_timeout"
label="Timeout"
slotProps={{
@@ -273,7 +294,7 @@ const ApplicationSettings = () => {
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="syslog_host"
label="Host"
variant="outlined"
@@ -284,7 +305,7 @@ const ApplicationSettings = () => {
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="syslog_port"
label="Port"
variant="outlined"
@@ -307,15 +328,15 @@ const ApplicationSettings = () => {
>
<MenuItem value={-1}>OFF</MenuItem>
<MenuItem value={3}>ERR</MenuItem>
<MenuItem value={4}>WARN</MenuItem>
<MenuItem value={5}>NOTICE</MenuItem>
<MenuItem value={6}>INFO</MenuItem>
<MenuItem value={7}>DEBUG</MenuItem>
<MenuItem value={9}>ALL</MenuItem>
</TextField>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="syslog_mark_interval"
label={LL.MARK_INTERVAL()}
slotProps={{
@@ -474,7 +495,7 @@ const ApplicationSettings = () => {
margin="normal"
select
>
{boardProfileSelectItems()}
{boardProfileItems}
<Divider />
<MenuItem key={'CUSTOM'} value={'CUSTOM'}>
{LL.CUSTOM()}&hellip;
@@ -485,7 +506,7 @@ const ApplicationSettings = () => {
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="rx_gpio"
label={LL.GPIO_OF('Rx')}
fullWidth
@@ -498,7 +519,7 @@ const ApplicationSettings = () => {
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="tx_gpio"
label={LL.GPIO_OF('Tx')}
fullWidth
@@ -511,7 +532,7 @@ const ApplicationSettings = () => {
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="pbutton_gpio"
label={LL.GPIO_OF(LL.BUTTON())}
fullWidth
@@ -524,7 +545,7 @@ const ApplicationSettings = () => {
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="dallas_gpio"
label={
LL.GPIO_OF(LL.TEMPERATURE()) + ' (0=' + LL.DISABLED(1) + ')'
@@ -539,7 +560,7 @@ const ApplicationSettings = () => {
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="led_gpio"
label={LL.GPIO_OF('LED') + ' (0=' + LL.DISABLED(1) + ')'}
fullWidth
@@ -554,7 +575,7 @@ const ApplicationSettings = () => {
<Grid>
<TextField
name="led_type"
label={'LED ' + LL.TYPE()}
label={'LED ' + LL.TYPE(0)}
value={data.led_type}
fullWidth
variant="outlined"
@@ -581,6 +602,7 @@ const ApplicationSettings = () => {
<MenuItem value={0}>{LL.DISABLED(1)}</MenuItem>
<MenuItem value={1}>LAN8720</MenuItem>
<MenuItem value={2}>TLK110</MenuItem>
<MenuItem value={3}>RTL8201</MenuItem>
</TextField>
</Grid>
</Grid>
@@ -743,7 +765,7 @@ const ApplicationSettings = () => {
{data.remote_timeout_en && (
<Box mt={2}>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="remote_timeout"
label={LL.REMOTE_TIMEOUT()}
slotProps={{
@@ -783,7 +805,7 @@ const ApplicationSettings = () => {
{data.shower_timer && (
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="shower_min_duration"
label={LL.MIN_DURATION()}
slotProps={{
@@ -801,7 +823,7 @@ const ApplicationSettings = () => {
<>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="shower_alert_trigger"
label={LL.TRIGGER_TIME()}
slotProps={{
@@ -817,7 +839,7 @@ const ApplicationSettings = () => {
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="shower_alert_coldshot"
label={LL.COLD_SHOT_DURATION()}
slotProps={{
@@ -836,8 +858,9 @@ const ApplicationSettings = () => {
</Grid>
{restartNeeded && (
<MessageBox my={2} level="warning" message={LL.RESTART_TEXT(0)}>
<MessageBox level="warning" message={LL.RESTART_TEXT(0)}>
<Button
sx={{ ml: 2 }}
startIcon={<PowerSettingsNewIcon />}
variant="contained"
color="error"

View File

@@ -1,8 +1,8 @@
import { useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import DownloadIcon from '@mui/icons-material/GetApp';
import { Box, Button, Grid2 as Grid, Typography } from '@mui/material';
import { Box, Button, Grid, Typography } from '@mui/material';
import * as SystemApi from 'api/system';
import { API, callAction } from 'api/app';
@@ -19,6 +19,13 @@ import {
import { useI18nContext } from 'i18n/i18n-react';
import { saveFile } from 'utils';
interface DownloadButton {
key: string;
type: string;
label: string | number;
isGridButton: boolean;
}
const DownloadUpload = () => {
const { LL } = useI18nContext();
@@ -35,7 +42,7 @@ const DownloadUpload = () => {
toast.info(LL.DOWNLOAD_SUCCESSFUL());
})
.onError((error) => {
toast.error(error.message);
toast.error(String(error.error?.message || 'An error occurred'));
});
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
@@ -44,95 +51,126 @@ const DownloadUpload = () => {
const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus);
const doRestart = async () => {
const doRestart = useCallback(async () => {
setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
};
try {
await sendAPI({ device: 'system', cmd: 'restart', id: 0 });
} catch (error) {
toast.error((error as Error).message);
setRestarting(false);
}
}, [sendAPI]);
useLayoutTitle(LL.DOWNLOAD_UPLOAD());
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
}
const downloadButtons: DownloadButton[] = useMemo(
() => [
{
key: 'settings',
type: 'settings',
label: LL.SETTINGS_OF(LL.APPLICATION()),
isGridButton: true
},
{
key: 'customizations',
type: 'customizations',
label: LL.CUSTOMIZATIONS(),
isGridButton: true
},
{
key: 'entities',
type: 'entities',
label: LL.CUSTOM_ENTITIES(0),
isGridButton: true
},
{
key: 'schedule',
type: 'schedule',
label: LL.SCHEDULE(0),
isGridButton: true
},
{
key: 'allvalues',
type: 'allvalues',
label: LL.ALLVALUES(),
isGridButton: false
}
],
[LL]
);
const handleDownload = useCallback(
(type: string) => () => {
void sendExportData(type);
},
[sendExportData]
);
if (restarting) {
return <SystemMonitor />;
}
if (!data) {
return (
<>
<Typography sx={{ pb: 2 }} variant="h6" color="primary">
{LL.DOWNLOAD(0)}
</Typography>
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
<Typography mb={1} variant="body1" color="warning">
{LL.DOWNLOAD_SETTINGS_TEXT()}.
</Typography>
<Grid container spacing={1}>
<Button
sx={{ ml: 2 }}
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={() => sendExportData('settings')}
>
{LL.SETTINGS_OF(LL.APPLICATION())}
</Button>
const gridButtons = downloadButtons.filter((btn) => btn.isGridButton);
const standaloneButton = downloadButtons.find((btn) => !btn.isGridButton);
<Button
sx={{ ml: 2 }}
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={() => sendExportData('customizations')}
>
{LL.CUSTOMIZATIONS()}
</Button>
<Button
sx={{ ml: 2 }}
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={() => sendExportData('entities')}
>
{LL.CUSTOM_ENTITIES(0)}
</Button>
<Button
sx={{ ml: 2 }}
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={() => sendExportData('schedule')}
>
{LL.SCHEDULE(0)}
</Button>
</Grid>
return (
<SectionContent>
<Typography sx={{ pb: 2 }} variant="h6" color="primary">
{LL.DOWNLOAD(0)}
</Typography>
<Typography mb={1} variant="body1" color="warning">
{LL.DOWNLOAD_SETTINGS_TEXT()}.
</Typography>
<Grid container spacing={2}>
{gridButtons.map((button) => (
<Grid key={button.key}>
<Button
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={handleDownload(button.type)}
>
{button.label}
</Button>
</Grid>
))}
</Grid>
<Typography mt={2} mb={1} variant="body1" color="warning">
{LL.DOWNLOAD_SETTINGS_TEXT2()}.
</Typography>
{standaloneButton && (
<Button
sx={{ ml: 2, mt: 2 }}
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={() => sendExportData('allvalues')}
onClick={handleDownload(standaloneButton.type)}
>
{LL.ALLVALUES()}
{standaloneButton.label}
</Button>
)}
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
{LL.UPLOAD()}
</Typography>
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
{LL.UPLOAD()}
</Typography>
<Box color="warning.main" sx={{ pb: 2 }}>
<Typography variant="body1">{LL.UPLOAD_TEXT()}.</Typography>
</Box>
<Box color="warning.main" sx={{ pb: 2 }}>
<Typography variant="body1">{LL.UPLOAD_TEXT()}.</Typography>
</Box>
<SingleUpload text={LL.UPLOAD_DRAG()} doRestart={doRestart} />
</>
);
};
return (
<SectionContent>{restarting ? <SystemMonitor /> : content()}</SectionContent>
<SingleUpload text={LL.UPLOAD_DRAG()} doRestart={doRestart} />
</SectionContent>
);
};

View File

@@ -1,11 +1,14 @@
import { useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
Button,
Checkbox,
Grid2 as Grid,
Grid,
InputAdornment,
MenuItem,
TextField,
@@ -30,6 +33,8 @@ import type { MqttSettingsType } from 'types';
import { numberValue, updateValueDirty, useRest } from 'utils';
import { createMqttSettingsValidator, validate } from 'validators';
import { callAction } from '../../api/app';
const MqttSettings = () => {
const {
loadData,
@@ -52,48 +57,104 @@ const MqttSettings = () => {
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValueDirty(
origData,
dirtyFlags,
setDirtyFlags,
updateDataValue
const sendResetMQTT = useCallback(() => {
void callAction({ action: 'resetMQTT' })
.then(() => {
toast.success('MQTT ' + LL.REFRESH() + ' successful');
})
.catch((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
}, []);
const updateFormValue = useMemo(
() =>
updateValueDirty(
origData as unknown as Record<string, unknown>,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
);
const SecondsInputProps = {
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
};
const SecondsInputProps = useMemo(
() => ({
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}),
[LL]
);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
const emptyFieldErrors = useMemo(() => ({}), []);
const validateAndSubmit = useCallback(async () => {
if (!data) return;
try {
setFieldErrors(undefined);
await validate(createMqttSettingsValidator(data), data);
await saveData();
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
}, [data, saveData]);
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(createMqttSettingsValidator(data), data);
await saveData();
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
};
const publishIntervalFields = useMemo(
() => [
{ name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true },
{ name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false },
{
name: 'publish_time_thermostat',
label: LL.MQTT_INT_THERMOSTATS(),
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_water', label: LL.MQTT_INT_WATER(), validated: false },
{ name: 'publish_time_sensor', label: LL.SENSORS(), validated: false },
{ name: 'publish_time_other', label: LL.DEFAULT(0), validated: false }
],
[LL]
);
if (!data) {
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
<FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />
</SectionContent>
);
}
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
<>
<BlockFormControlLabel
control={
<Checkbox
name="enabled"
checked={data.enabled}
onChange={updateFormValue}
/>
}
label={LL.ENABLE_MQTT()}
/>
<Box display="flex" gap={2} mb={1}>
<BlockFormControlLabel
control={
<Checkbox
name="enabled"
checked={data.enabled}
onChange={updateFormValue}
/>
}
label={LL.ENABLE_MQTT()}
/>
{data.enabled && (
<Button
startIcon={<SettingsBackupRestoreIcon />}
color="secondary"
variant="outlined"
onClick={sendResetMQTT}
>
{LL.REFRESH() + ' MQTT'}
</Button>
)}
</Box>
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors ?? emptyFieldErrors}
name="host"
label={LL.ADDRESS_OF(LL.BROKER())}
multiline
@@ -105,7 +166,7 @@ const MqttSettings = () => {
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors ?? emptyFieldErrors}
name="port"
label="Port"
variant="outlined"
@@ -117,7 +178,7 @@ const MqttSettings = () => {
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors ?? emptyFieldErrors}
name="base"
label={LL.BASE_TOPIC()}
variant="outlined"
@@ -129,7 +190,7 @@ const MqttSettings = () => {
<Grid>
<TextField
name="client_id"
label={LL.ID_OF(LL.CLIENT()) + ' (' + LL.OPTIONAL() + ')'}
label={`${LL.ID_OF(LL.CLIENT())} (${LL.OPTIONAL()})`}
variant="outlined"
value={data.client_id}
onChange={updateFormValue}
@@ -158,7 +219,7 @@ const MqttSettings = () => {
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors ?? emptyFieldErrors}
name="keep_alive"
label="Keep Alive"
slotProps={{
@@ -205,6 +266,7 @@ const MqttSettings = () => {
label={LL.CERT()}
variant="outlined"
value={data.rootCA}
sx={{ width: '50ch' }}
onChange={updateFormValue}
margin="normal"
/>
@@ -254,219 +316,144 @@ const MqttSettings = () => {
}
label={LL.MQTT_RESPONSE()}
/>
{!data.ha_enabled && (
<Grid container spacing={2} rowSpacing={0}>
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<BlockFormControlLabel
control={
<Checkbox
name="publish_single"
checked={data.publish_single}
onChange={updateFormValue}
disabled={data.ha_enabled}
/>
}
label={LL.MQTT_PUBLISH_TEXT_1()}
/>
</Grid>
{data.publish_single && (
<Grid>
<BlockFormControlLabel
control={
<Checkbox
name="publish_single"
checked={data.publish_single}
name="publish_single2cmd"
checked={data.publish_single2cmd}
onChange={updateFormValue}
/>
}
label={LL.MQTT_PUBLISH_TEXT_1()}
label={LL.MQTT_PUBLISH_TEXT_2()}
/>
</Grid>
{data.publish_single && (
)}
</Grid>
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<BlockFormControlLabel
control={
<Checkbox
name="ha_enabled"
checked={data.ha_enabled}
onChange={updateFormValue}
disabled={data.publish_single}
/>
}
label={LL.MQTT_PUBLISH_TEXT_3()}
/>
</Grid>
{data.ha_enabled && (
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<BlockFormControlLabel
control={
<Checkbox
name="publish_single2cmd"
checked={data.publish_single2cmd}
onChange={updateFormValue}
/>
}
label={LL.MQTT_PUBLISH_TEXT_2()}
<TextField
name="discovery_type"
label={LL.MQTT_PUBLISH_TEXT_5()}
value={data.discovery_type}
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={0}>Home Assistant</MenuItem>
<MenuItem value={1}>Domoticz</MenuItem>
<MenuItem value={2}>Domoticz (latest)</MenuItem>
</TextField>
</Grid>
<Grid>
<TextField
name="discovery_prefix"
label={LL.MQTT_PUBLISH_TEXT_4()}
variant="outlined"
value={data.discovery_prefix}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
)}
</Grid>
)}
{!data.publish_single && (
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<BlockFormControlLabel
control={
<Checkbox
name="ha_enabled"
checked={data.ha_enabled}
onChange={updateFormValue}
/>
}
label={LL.MQTT_PUBLISH_TEXT_3()}
/>
</Grid>
{data.ha_enabled && (
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<TextField
name="discovery_type"
label={LL.MQTT_PUBLISH_TEXT_5()}
value={data.discovery_type}
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={0}>Home Assistant</MenuItem>
<MenuItem value={1}>Domoticz</MenuItem>
<MenuItem value={2}>Domoticz (latest)</MenuItem>
</TextField>
</Grid>
<Grid>
<TextField
name="discovery_prefix"
label={LL.MQTT_PUBLISH_TEXT_4()}
variant="outlined"
value={data.discovery_prefix}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<TextField
name="entity_format"
label={LL.MQTT_ENTITY_FORMAT()}
value={data.entity_format}
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={0}>{LL.MQTT_ENTITY_FORMAT_0()}</MenuItem>
<MenuItem value={3}>
{LL.MQTT_ENTITY_FORMAT_1()}&nbsp;(v3.6)
</MenuItem>
<MenuItem value={4}>
{LL.MQTT_ENTITY_FORMAT_2()}&nbsp;(v3.6)
</MenuItem>
<MenuItem value={1}>{LL.MQTT_ENTITY_FORMAT_1()}</MenuItem>
<MenuItem value={2}>{LL.MQTT_ENTITY_FORMAT_2()}</MenuItem>
</TextField>
</Grid>
<Grid>
<TextField
name="entity_format"
label={LL.MQTT_ENTITY_FORMAT()}
value={data.entity_format}
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={0}>{LL.MQTT_ENTITY_FORMAT_0()}</MenuItem>
<MenuItem value={3}>
{LL.MQTT_ENTITY_FORMAT_1()}&nbsp;(v3.5)
</MenuItem>
<MenuItem value={4}>
{LL.MQTT_ENTITY_FORMAT_2()}&nbsp;(v3.5)
</MenuItem>
<MenuItem value={1}>
{LL.MQTT_ENTITY_FORMAT_1()}&nbsp;(latest)
</MenuItem>
<MenuItem value={2}>
{LL.MQTT_ENTITY_FORMAT_2()}&nbsp;(latest)
</MenuItem>
</TextField>
</Grid>
)}
</Grid>
)}
</Grid>
)}
</Grid>
<Typography sx={{ pt: 2 }} variant="h6" color="primary">
{LL.MQTT_PUBLISH_INTERVALS()}&nbsp;(0=auto)
</Typography>
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
name="publish_time_heartbeat"
label="Heartbeat"
slotProps={{
input: SecondsInputProps
}}
variant="outlined"
value={numberValue(data.publish_time_heartbeat)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<TextField
name="publish_time_boiler"
label={LL.MQTT_INT_BOILER()}
variant="outlined"
value={numberValue(data.publish_time_boiler)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_thermostat"
label={LL.MQTT_INT_THERMOSTATS()}
variant="outlined"
value={numberValue(data.publish_time_thermostat)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_solar"
label={LL.MQTT_INT_SOLAR()}
variant="outlined"
value={numberValue(data.publish_time_solar)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_mixer"
label={LL.MQTT_INT_MIXER()}
variant="outlined"
value={numberValue(data.publish_time_mixer)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_water"
label={LL.MQTT_INT_WATER()}
variant="outlined"
value={numberValue(data.publish_time_water)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_sensor"
label={LL.TEMP_SENSORS()}
variant="outlined"
value={numberValue(data.publish_time_sensor)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_other"
label={LL.DEFAULT(0)}
variant="outlined"
value={numberValue(data.publish_time_other)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
{publishIntervalFields.map((field) => (
<Grid key={field.name}>
{field.validated ? (
<ValidatedTextField
fieldErrors={fieldErrors ?? emptyFieldErrors}
name={field.name}
label={field.label}
slotProps={{
input: SecondsInputProps
}}
variant="outlined"
value={numberValue(
data[field.name as keyof MqttSettingsType] as number
)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
) : (
<TextField
name={field.name}
label={field.label}
variant="outlined"
value={numberValue(
data[field.name as keyof MqttSettingsType] as number
)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
)}
</Grid>
))}
</Grid>
{dirtyFlags && dirtyFlags.length !== 0 && (
<ButtonRow>
@@ -493,13 +480,6 @@ const MqttSettings = () => {
</ButtonRow>
)}
</>
);
};
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{content()}
</SectionContent>
);
};

View File

@@ -1,12 +1,27 @@
import { useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning';
import { Button, Checkbox, MenuItem } from '@mui/material';
import {
Box,
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
MenuItem,
TextField,
Typography
} from '@mui/material';
import * as NTPApi from 'api/ntp';
import { readNTPSettings } from 'api/ntp';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import { updateState } from 'alova/client';
import type { ValidateFieldsError } from 'async-validator';
import {
@@ -19,12 +34,12 @@ import {
useLayoutTitle
} from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import type { NTPSettingsType } from 'types';
import { updateValueDirty, useRest } from 'utils';
import type { NTPSettingsType, Time } from 'types';
import { formatLocalDateTime, updateValueDirty, useRest } from 'utils';
import { validate } from 'validators';
import { NTP_SETTINGS_VALIDATOR } from 'validators/ntp';
import { TIME_ZONES, selectedTimeZone, timeZoneSelectItems } from './TZ';
import { TIME_ZONES, selectedTimeZone, useTimeZoneSelectItems } from './TZ';
const NTPSettings = () => {
const {
@@ -46,38 +61,100 @@ const NTPSettings = () => {
const { LL } = useI18nContext();
useLayoutTitle('NTP');
const updateFormValue = updateValueDirty(
origData,
dirtyFlags,
setDirtyFlags,
updateDataValue
// Memoized timezone select items for better performance
const timeZoneItems = useTimeZoneSelectItems();
// Memoized selected timezone value
const selectedTzValue = useMemo(
() => (data ? selectedTimeZone(data.tz_label, data.tz_format) : undefined),
[data?.tz_label, data?.tz_format]
);
const [localTime, setLocalTime] = useState<string>('');
const [settingTime, setSettingTime] = useState<boolean>(false);
const [processing, setProcessing] = useState<boolean>(false);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
const { send: updateTime } = useRequest(
(local_time: Time) => NTPApi.updateTime(local_time),
{
immediate: false
}
);
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(NTP_SETTINGS_VALIDATOR, data);
await saveData();
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
};
// Memoize updateFormValue to prevent recreation on every render
const updateFormValue = useMemo(
() =>
updateValueDirty(
origData as unknown as Record<string, unknown>,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
);
const changeTimeZone = (event: React.ChangeEvent<HTMLInputElement>) => {
// Memoize updateLocalTime handler
const updateLocalTime = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => setLocalTime(event.target.value),
[]
);
// Memoize openSetTime handler
const openSetTime = useCallback(() => {
setLocalTime(formatLocalDateTime(new Date()));
setSettingTime(true);
}, []);
// Memoize configureTime handler
const configureTime = useCallback(async () => {
setProcessing(true);
try {
await updateTime({ local_time: formatLocalDateTime(new Date(localTime)) });
toast.success(LL.TIME_SET());
setSettingTime(false);
await loadData();
} catch {
toast.error(LL.PROBLEM_UPDATING());
} finally {
setProcessing(false);
}
}, [localTime, updateTime, LL, loadData]);
// Memoize close dialog handler
const handleCloseSetTime = useCallback(() => setSettingTime(false), []);
// Memoize validate and submit handler
const validateAndSubmit = useCallback(async () => {
if (!data) return;
try {
setFieldErrors(undefined);
await validate(NTP_SETTINGS_VALIDATOR, data);
await saveData();
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
}, [data, saveData]);
// Memoize timezone change handler
const changeTimeZone = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
...settings,
tz_label: event.target.value,
tz_format: TIME_ZONES[event.target.value]
}));
updateFormValue(event);
};
},
[updateFormValue]
);
// Memoize render content to prevent unnecessary re-renders
const renderContent = useMemo(() => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
return (
<>
@@ -92,7 +169,7 @@ const NTPSettings = () => {
label={LL.ENABLE_NTP()}
/>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="server"
label={LL.NTP_SERVER()}
fullWidth
@@ -102,19 +179,37 @@ const NTPSettings = () => {
margin="normal"
/>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="tz_label"
label={LL.TIME_ZONE()}
fullWidth
variant="outlined"
value={selectedTimeZone(data.tz_label, data.tz_format)}
value={selectedTzValue}
onChange={changeTimeZone}
margin="normal"
select
>
<MenuItem disabled>{LL.TIME_ZONE()}...</MenuItem>
{timeZoneSelectItems()}
{timeZoneItems}
</ValidatedTextField>
<Box display="flex" flexWrap="wrap">
{!data.enabled && !dirtyFlags.length && (
<Box flexWrap="nowrap" whiteSpace="nowrap">
<ButtonRow>
<Button
onClick={openSetTime}
variant="outlined"
color="primary"
startIcon={<AccessTimeIcon />}
>
{LL.SET_TIME(0)}
</Button>
</ButtonRow>
</Box>
)}
</Box>
{dirtyFlags && dirtyFlags.length !== 0 && (
<ButtonRow>
<Button
@@ -141,12 +236,66 @@ const NTPSettings = () => {
)}
</>
);
};
}, [
data,
errorMessage,
loadData,
updateFormValue,
fieldErrors,
selectedTzValue,
changeTimeZone,
timeZoneItems,
dirtyFlags,
openSetTime,
saving,
validateAndSubmit,
LL
]);
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{content()}
{renderContent}
<Dialog sx={dialogStyle} open={settingTime} onClose={handleCloseSetTime}>
<DialogTitle>{LL.SET_TIME(1)}</DialogTitle>
<DialogContent dividers>
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
<Typography variant="body2">{LL.SET_TIME_TEXT()}</Typography>
</Box>
<TextField
label={LL.LOCAL_TIME(0)}
type="datetime-local"
value={localTime}
onChange={updateLocalTime}
disabled={processing}
fullWidth
slotProps={{
inputLabel: {
shrink: true
}
}}
/>
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={handleCloseSetTime}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<AccessTimeIcon />}
variant="outlined"
onClick={configureTime}
disabled={processing}
color="primary"
>
{LL.UPDATE()}
</Button>
</DialogActions>
</Dialog>
</SectionContent>
);
};

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -17,6 +17,7 @@ import {
DialogActions,
DialogContent,
DialogTitle,
Divider,
List
} from '@mui/material';
@@ -29,134 +30,159 @@ import { SectionContent, useLayoutTitle } from 'components';
import ListMenuItem from 'components/layout/ListMenuItem';
import { useI18nContext } from 'i18n/i18n-react';
import SystemMonitor from '../status/SystemMonitor';
const Settings = () => {
const { LL } = useI18nContext();
useLayoutTitle(LL.SETTINGS(0));
const [confirmFactoryReset, setConfirmFactoryReset] = useState<boolean>(false);
const [confirmFactoryReset, setConfirmFactoryReset] = useState(false);
const [restarting, setRestarting] = useState<boolean>();
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false
});
const doFormat = async () => {
const doFormat = useCallback(async () => {
await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => {
setRestarting(true);
setConfirmFactoryReset(false);
});
};
}, [sendAPI]);
const renderFactoryResetDialog = () => (
<Dialog
sx={dialogStyle}
open={confirmFactoryReset}
onClose={() => setConfirmFactoryReset(false)}
>
<DialogTitle>{LL.FACTORY_RESET()}</DialogTitle>
<DialogContent dividers>{LL.SYSTEM_FACTORY_TEXT_DIALOG()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={() => setConfirmFactoryReset(false)}
color="secondary"
const handleFactoryResetClose = useCallback(() => {
setConfirmFactoryReset(false);
}, []);
const handleFactoryResetClick = useCallback(() => {
setConfirmFactoryReset(true);
}, []);
const content = useMemo(() => {
return (
<>
<List>
<ListMenuItem
icon={TuneIcon}
bgcolor="#134ba2"
label={LL.APPLICATION()}
text={LL.APPLICATION_SETTINGS_1()}
to="application"
/>
<ListMenuItem
icon={SettingsEthernetIcon}
bgcolor="#40828f"
label={LL.NETWORK(0)}
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))}
to="network"
/>
<ListMenuItem
icon={SettingsInputAntennaIcon}
bgcolor="#5f9a5f"
label={LL.ACCESS_POINT(0)}
text={LL.CONFIGURE(LL.ACCESS_POINT(1))}
to="ap"
/>
<ListMenuItem
icon={AccessTimeIcon}
bgcolor="#c5572c"
label="NTP"
text={LL.CONFIGURE(LL.LOCAL_TIME(1))}
to="ntp"
/>
<ListMenuItem
icon={DeviceHubIcon}
bgcolor="#68374d"
label="MQTT"
text={LL.CONFIGURE('MQTT')}
to="mqtt"
/>
<ListMenuItem
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}
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={doFormat}
color="error"
<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
mt={2}
display="flex"
justifyContent="flex-end"
flexWrap="nowrap"
whiteSpace="nowrap"
>
{LL.FACTORY_RESET()}
</Button>
</DialogActions>
</Dialog>
);
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={handleFactoryResetClick}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
</Box>
</>
);
}, [
LL,
handleFactoryResetClick,
handleFactoryResetClose,
doFormat,
confirmFactoryReset,
restarting
]);
const content = () => (
<>
<List sx={{ borderRadius: 3, border: '2px solid grey' }}>
<ListMenuItem
icon={TuneIcon}
bgcolor="#134ba2"
label={LL.APPLICATION()}
text={LL.APPLICATION_SETTINGS_1()}
to="application"
/>
<ListMenuItem
icon={SettingsEthernetIcon}
bgcolor="#40828f"
label={LL.NETWORK(0)}
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))}
to="network"
/>
<ListMenuItem
icon={SettingsInputAntennaIcon}
bgcolor="#5f9a5f"
label={LL.ACCESS_POINT(0)}
text={LL.CONFIGURE(LL.ACCESS_POINT(1))}
to="ap"
/>
<ListMenuItem
icon={AccessTimeIcon}
bgcolor="#c5572c"
label="NTP"
text={LL.CONFIGURE(LL.LOCAL_TIME(1))}
to="ntp"
/>
<ListMenuItem
icon={DeviceHubIcon}
bgcolor="#68374d"
label="MQTT"
text={LL.CONFIGURE('MQTT')}
to="mqtt"
/>
<ListMenuItem
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>
{renderFactoryResetDialog()}
<Box mt={2} display="flex" flexWrap="wrap">
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={() => setConfirmFactoryReset(true)}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
</Box>
</>
);
return <SectionContent>{content()}</SectionContent>;
return <SectionContent>{restarting ? <SystemMonitor /> : content}</SectionContent>;
};
export default Settings;

View File

@@ -1,8 +1,8 @@
import { useMemo } from 'react';
import { MenuItem } from '@mui/material';
type TimeZones = Record<string, string>;
export const TIME_ZONES: TimeZones = {
export const TIME_ZONES: Record<string, string> = {
'Africa/Abidjan': 'GMT0',
'Africa/Accra': 'GMT0',
'Africa/Addis_Ababa': 'EAT-3',
@@ -465,14 +465,33 @@ export const TIME_ZONES: TimeZones = {
'Pacific/Wallis': 'UNK-12'
};
// Pre-compute sorted timezone labels for better performance
export const TIME_ZONE_LABELS = Object.keys(TIME_ZONES).sort();
export function selectedTimeZone(label: string, format: string) {
return TIME_ZONES[label] === format ? label : undefined;
}
export function timeZoneSelectItems() {
return Object.keys(TIME_ZONES).map((label) => (
<MenuItem key={label} value={label}>
{label}
</MenuItem>
));
// 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) => (
<MenuItem key={label} value={label}>
{label}
</MenuItem>
));
export function timeZoneSelectItems() {
return precomputedTimeZoneItems;
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import {
Navigate,
Route,
@@ -28,14 +28,13 @@ const Network = () => {
[
{
path: '/settings/network/settings',
element: <NetworkSettings />,
dog: 'woof'
element: <NetworkSettings />
},
{ path: '/settings/network/scan', element: <WiFiNetworkScanner /> }
],
useLocation()
);
const routerTab = matchedRoutes?.[0].route.path || false;
const routerTab = matchedRoutes?.[0]?.route.path || false;
const navigate = useNavigate();
@@ -53,14 +52,17 @@ const Network = () => {
setSelectedNetwork(undefined);
}, []);
const contextValue = useMemo(
() => ({
...(selectedNetwork && { selectedNetwork }),
selectNetwork,
deselectNetwork
}),
[selectedNetwork, selectNetwork, deselectNetwork]
);
return (
<WiFiConnectionContext.Provider
value={{
selectedNetwork,
selectNetwork,
deselectNetwork
}}
>
<WiFiConnectionContext.Provider value={contextValue}>
<RouterTabs value={routerTab}>
<Tab
value="/settings/network/settings"
@@ -80,4 +82,4 @@ const Network = () => {
);
};
export default Network;
export default memo(Network);

View File

@@ -1,4 +1,4 @@
import { useContext, useEffect, useState } from 'react';
import { memo, useCallback, useContext, useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -104,43 +104,42 @@ const NetworkSettings = () => {
origData,
dirtyFlags,
setDirtyFlags,
updateDataValue
updateDataValue as (value: unknown) => void
);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
useEffect(() => deselectNetwork, [deselectNetwork]);
const validateAndSubmit = useCallback(async () => {
if (!data) return;
try {
setFieldErrors(undefined);
await validate(createNetworkSettingsValidator(data), data);
await saveData();
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
deselectNetwork();
}, [data, saveData, deselectNetwork]);
const setCancel = useCallback(async () => {
deselectNetwork();
await loadData();
}, [deselectNetwork, loadData]);
const doRestart = useCallback(async () => {
setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
}, [sendAPI]);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(createNetworkSettingsValidator(data), data);
await saveData();
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
deselectNetwork();
};
const setCancel = async () => {
deselectNetwork();
await loadData();
};
const doRestart = async () => {
setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
};
return (
<>
<Typography variant="h6" color="primary">
@@ -165,14 +164,14 @@ const NetworkSettings = () => {
selectedNetwork.bssid
}
/>
<IconButton onClick={setCancel}>
<IconButton onClick={setCancel} aria-label={LL.CANCEL()}>
<DeleteIcon />
</IconButton>
</ListItem>
</List>
) : (
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="ssid"
label={'SSID (' + LL.NETWORK_BLANK_SSID() + ')'}
fullWidth
@@ -183,7 +182,7 @@ const NetworkSettings = () => {
/>
)}
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="bssid"
label={'BSSID (' + LL.NETWORK_BLANK_BSSID() + ')'}
fullWidth
@@ -194,7 +193,7 @@ const NetworkSettings = () => {
/>
{(!selectedNetwork || !isNetworkOpen(selectedNetwork)) && (
<ValidatedPasswordField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="password"
label={LL.PASSWORD()}
fullWidth
@@ -251,7 +250,7 @@ const NetworkSettings = () => {
{LL.GENERAL_OPTIONS()}
</Typography>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="hostname"
label={LL.HOSTNAME()}
fullWidth
@@ -304,7 +303,7 @@ const NetworkSettings = () => {
{data.static_ip_config && (
<>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="local_ip"
label={LL.AP_LOCAL_IP()}
fullWidth
@@ -314,7 +313,7 @@ const NetworkSettings = () => {
margin="normal"
/>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="gateway_ip"
label={LL.NETWORK_GATEWAY()}
fullWidth
@@ -324,7 +323,7 @@ const NetworkSettings = () => {
margin="normal"
/>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="subnet_mask"
label={LL.NETWORK_SUBNET()}
fullWidth
@@ -334,7 +333,7 @@ const NetworkSettings = () => {
margin="normal"
/>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="dns_ip_1"
label="DNS #1"
fullWidth
@@ -344,7 +343,7 @@ const NetworkSettings = () => {
margin="normal"
/>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="dns_ip_2"
label="DNS #2"
fullWidth
@@ -356,8 +355,9 @@ const NetworkSettings = () => {
</>
)}
{restartNeeded && (
<MessageBox my={2} level="warning" message={LL.RESTART_TEXT(0)}>
<MessageBox level="warning" message={LL.RESTART_TEXT(0)}>
<Button
sx={{ ml: 2 }}
startIcon={<PowerSettingsNewIcon />}
variant="contained"
color="error"
@@ -405,4 +405,4 @@ const NetworkSettings = () => {
);
};
export default NetworkSettings;
export default memo(NetworkSettings);

View File

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

View File

@@ -1,4 +1,4 @@
import { useContext } from 'react';
import { memo, useCallback, useContext } from 'react';
import LockIcon from '@mui/icons-material/Lock';
import LockOpenIcon from '@mui/icons-material/LockOpen';
@@ -63,38 +63,41 @@ const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList })
const wifiConnectionContext = useContext(WiFiConnectionContext);
const renderNetwork = (network: WiFiNetwork) => (
<ListItem
key={network.bssid}
onClick={() => wifiConnectionContext.selectNetwork(network)}
>
<ListItemAvatar>
<Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
</ListItemAvatar>
<ListItemText
primary={network.ssid}
secondary={
'Security: ' +
networkSecurityMode(network) +
', Ch: ' +
network.channel +
', bssid: ' +
network.bssid
}
/>
<ListItemIcon>
<Badge badgeContent={network.rssi + 'dBm'}>
<WifiIcon sx={{ color: networkQualityHighlight(network, theme) }} />
</Badge>
</ListItemIcon>
</ListItem>
const renderNetwork = useCallback(
(network: WiFiNetwork) => (
<ListItem
key={network.bssid}
onClick={() => wifiConnectionContext.selectNetwork(network)}
>
<ListItemAvatar>
<Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
</ListItemAvatar>
<ListItemText
primary={network.ssid}
secondary={
'Security: ' +
networkSecurityMode(network) +
', Ch: ' +
network.channel +
', bssid: ' +
network.bssid
}
/>
<ListItemIcon>
<Badge badgeContent={network.rssi + 'dBm'}>
<WifiIcon sx={{ color: networkQualityHighlight(network, theme) }} />
</Badge>
</ListItemIcon>
</ListItem>
),
[wifiConnectionContext, theme]
);
if (networkList.networks.length === 0) {
return <MessageBox mt={2} mb={1} message={LL.NETWORK_NO_WIFI()} level="info" />;
return <MessageBox message={LL.NETWORK_NO_WIFI()} level="info" />;
}
return <List>{networkList.networks.map(renderNetwork)}</List>;
};
export default WiFiNetworkSelector;
export default memo(WiFiNetworkSelector);

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { memo, useEffect } from 'react';
import CloseIcon from '@mui/icons-material/Close';
import {
@@ -40,7 +40,7 @@ const GenerateToken = ({ username, onClose }: GenerateTokenProps) => {
if (open) {
void generateToken();
}
}, [open]);
}, [open, generateToken]);
return (
<Dialog
@@ -86,4 +86,4 @@ const GenerateToken = ({ username, onClose }: GenerateTokenProps) => {
);
};
export default GenerateToken;
export default memo(GenerateToken);

View File

@@ -1,4 +1,4 @@
import { useContext, useState } from 'react';
import { memo, useCallback, useContext, useMemo, useState } from 'react';
import { useBlocker } from 'react-router';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -55,14 +55,16 @@ const ManageUsers = () => {
const blocker = useBlocker(changed !== 0);
const { LL } = useI18nContext();
const table_theme = useTheme({
Table: `
const table_theme = useMemo(
() =>
useTheme({
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) minmax(120px, max-content) 120px;
`,
BaseRow: `
BaseRow: `
font-size: 14px;
`,
HeaderRow: `
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
@@ -72,7 +74,7 @@ const ManageUsers = () => {
border-bottom: 1px solid #565656;
}
`,
Row: `
Row: `
.td {
padding: 8px;
border-top: 1px solid #565656;
@@ -85,7 +87,7 @@ const ManageUsers = () => {
background-color: #1e1e1e;
}
`,
BaseCell: `
BaseCell: `
&:nth-of-type(2) {
text-align: center;
}
@@ -93,71 +95,80 @@ const ManageUsers = () => {
text-align: right;
}
`
});
}),
[]
);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
const noAdminConfigured = useCallback(
() => !data?.users.find((u) => u.admin),
[data]
);
const noAdminConfigured = () => !data.users.find((u) => u.admin);
const removeUser = (toRemove: UserType) => {
const removeUser = useCallback(
(toRemove: UserType) => {
if (!data) return;
const users = data.users.filter((u) => u.username !== toRemove.username);
updateDataValue({ ...data, users });
setChanged(changed + 1);
};
},
[data, updateDataValue, changed]
);
const createUser = () => {
setCreating(true);
setUser({
username: '',
password: '',
admin: true
});
};
const createUser = useCallback(() => {
setCreating(true);
setUser({
username: '',
password: '',
admin: true
});
}, []);
const editUser = (toEdit: UserType) => {
setCreating(false);
setUser({ ...toEdit });
};
const editUser = useCallback((toEdit: UserType) => {
setCreating(false);
setUser({ ...toEdit });
}, []);
const cancelEditingUser = () => {
const cancelEditingUser = useCallback(() => {
setUser(undefined);
}, []);
const doneEditingUser = useCallback(() => {
if (user && data) {
const users = [
...data.users.filter(
(u: { username: string }) => u.username !== user.username
),
user
];
updateDataValue({ ...data, users });
setUser(undefined);
};
setChanged(changed + 1);
}
}, [user, data, updateDataValue, changed]);
const doneEditingUser = () => {
if (user) {
const users = [
...data.users.filter(
(u: { username: string }) => u.username !== user.username
),
user
];
updateDataValue({ ...data, users });
setUser(undefined);
setChanged(changed + 1);
}
};
const closeGenerateToken = useCallback(() => {
setGeneratingToken(undefined);
}, []);
const closeGenerateToken = () => {
setGeneratingToken(undefined);
};
const generateTokenForUser = useCallback((username: string) => {
setGeneratingToken(username);
}, []);
const generateToken = (username: string) => {
setGeneratingToken(username);
};
const onSubmit = useCallback(async () => {
await saveData();
await authenticatedContext.refresh();
setChanged(0);
}, [saveData, authenticatedContext]);
const onSubmit = async () => {
await saveData();
await authenticatedContext.refresh();
setChanged(0);
};
const onCancelSubmit = useCallback(async () => {
await loadData();
setChanged(0);
}, [loadData]);
const onCancelSubmit = async () => {
await loadData();
setChanged(0);
};
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
interface UserType2 {
id: string;
@@ -167,10 +178,14 @@ const ManageUsers = () => {
}
// add id to the type, needed for the table
const user_table = data.users.map((u) => ({
...u,
id: u.username
})) as UserType2[];
const user_table = useMemo(
() =>
data.users.map((u) => ({
...u,
id: u.username
})) as UserType2[],
[data.users]
);
return (
<>
@@ -196,15 +211,24 @@ const ManageUsers = () => {
<Cell stiff>
<IconButton
size="small"
aria-label={LL.GENERATING_TOKEN()}
disabled={!authenticatedContext.me.admin}
onClick={() => generateToken(u.username)}
onClick={() => generateTokenForUser(u.username)}
>
<VpnKeyIcon />
</IconButton>
<IconButton size="small" onClick={() => removeUser(u)}>
<IconButton
size="small"
onClick={() => removeUser(u)}
aria-label={LL.REMOVE()}
>
<DeleteIcon />
</IconButton>
<IconButton size="small" onClick={() => editUser(u)}>
<IconButton
size="small"
onClick={() => editUser(u)}
aria-label={LL.EDIT()}
>
<EditIcon />
</IconButton>
</Cell>
@@ -260,15 +284,20 @@ const ManageUsers = () => {
</Box>
</Box>
<GenerateToken username={generatingToken} onClose={closeGenerateToken} />
<User
user={user}
setUser={setUser}
creating={creating}
onDoneEditing={doneEditingUser}
onCancelEditing={cancelEditingUser}
validator={createUserValidator(data.users, creating)}
<GenerateToken
username={generatingToken || ''}
onClose={closeGenerateToken}
/>
{user && (
<User
user={user}
setUser={setUser}
creating={creating}
onDoneEditing={doneEditingUser}
onCancelEditing={cancelEditingUser}
validator={createUserValidator(data.users, creating)}
/>
)}
</>
);
};
@@ -281,4 +310,4 @@ const ManageUsers = () => {
);
};
export default ManageUsers;
export default memo(ManageUsers);

View File

@@ -1,3 +1,4 @@
import { memo, useMemo } from 'react';
import { Navigate, Route, Routes, matchRoutes, useLocation } from 'react-router';
import { Tab } from '@mui/material';
@@ -12,14 +13,23 @@ const Security = () => {
const { LL } = useI18nContext();
useLayoutTitle(LL.SECURITY(0));
const matchedRoutes = matchRoutes(
[
{ path: '/settings/security/settings', element: <ManageUsers />, dog: 'woof' },
{ path: '/settings/security/users', element: <SecuritySettings /> }
],
useLocation()
const location = useLocation();
const matchedRoutes = useMemo(
() =>
matchRoutes(
[
{
path: '/settings/security/settings',
element: <ManageUsers />
},
{ path: '/settings/security/users', element: <SecuritySettings /> }
],
location
),
[location]
);
const routerTab = matchedRoutes?.[0].route.path || false;
const routerTab = matchedRoutes?.[0]?.route.path || false;
return (
<>
@@ -42,4 +52,4 @@ const Security = () => {
);
};
export default Security;
export default memo(Security);

View File

@@ -1,4 +1,4 @@
import { useContext, useState } from 'react';
import { memo, useCallback, useContext, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning';
@@ -44,32 +44,33 @@ const SecuritySettings = () => {
const authenticatedContext = useContext(AuthenticatedContext);
const updateFormValue = updateValueDirty(
origData,
origData as unknown as Record<string, unknown>,
dirtyFlags,
setDirtyFlags,
updateDataValue
updateDataValue as (value: unknown) => void
);
const validateAndSubmit = useCallback(async () => {
if (!data) return;
try {
setFieldErrors(undefined);
await validate(SECURITY_SETTINGS_VALIDATOR, data);
await saveData();
await authenticatedContext.refresh();
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
}, [data, saveData, authenticatedContext]);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(SECURITY_SETTINGS_VALIDATOR, data);
await saveData();
await authenticatedContext.refresh();
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
};
return (
<>
<ValidatedPasswordField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="jwt_secret"
label={LL.SU_PASSWORD()}
fullWidth
@@ -115,4 +116,4 @@ const SecuritySettings = () => {
);
};
export default SecuritySettings;
export default memo(SecuritySettings);

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { memo, useCallback, useEffect, useState } from 'react';
import type { FC } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -45,7 +45,14 @@ const User: FC<UserFormProps> = ({
}) => {
const { LL } = useI18nContext();
const updateFormValue = updateValue(setUser);
const updateFormValue = updateValue((updater) => {
setUser((prevState) => {
if (!prevState) return prevState;
return updater(
prevState as unknown as Record<string, unknown>
) as unknown as UserType;
});
});
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const open = !!user;
@@ -55,7 +62,7 @@ const User: FC<UserFormProps> = ({
}
}, [open]);
const validateAndDone = async () => {
const validateAndDone = useCallback(async () => {
if (user) {
try {
setFieldErrors(undefined);
@@ -65,7 +72,7 @@ const User: FC<UserFormProps> = ({
setFieldErrors(error as ValidateFieldsError);
}
}
};
}, [user, validator, onDoneEditing]);
return (
<Dialog
@@ -82,7 +89,7 @@ const User: FC<UserFormProps> = ({
</DialogTitle>
<DialogContent dividers>
<ValidatedTextField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="username"
label={LL.USERNAME(1)}
fullWidth
@@ -93,7 +100,7 @@ const User: FC<UserFormProps> = ({
margin="normal"
/>
<ValidatedPasswordField
fieldErrors={fieldErrors}
fieldErrors={fieldErrors || {}}
name="password"
label={LL.PASSWORD()}
fullWidth
@@ -137,4 +144,4 @@ const User: FC<UserFormProps> = ({
);
};
export default User;
export default memo(User);

View File

@@ -34,37 +34,43 @@ export const apStatusHighlight = ({ status }: APStatusType, theme: Theme) => {
}
};
const getApStatusText = (
status: APNetworkStatus,
LL: ReturnType<typeof useI18nContext>['LL']
) => {
switch (status) {
case APNetworkStatus.ACTIVE:
return LL.ACTIVE();
case APNetworkStatus.INACTIVE:
return LL.INACTIVE(0);
case APNetworkStatus.LINGERING:
return 'Lingering until idle';
default:
return LL.UNKNOWN();
}
};
const APStatus = () => {
const { data, send: loadData, error } = useRequest(APApi.readAPStatus);
const { LL } = useI18nContext();
const theme = useTheme();
useLayoutTitle(LL.ACCESS_POINT(0));
useInterval(() => {
void loadData();
});
const { LL } = useI18nContext();
useLayoutTitle(LL.ACCESS_POINT(0));
const theme = useTheme();
const apStatus = ({ status }: APStatusType) => {
switch (status) {
case APNetworkStatus.ACTIVE:
return LL.ACTIVE();
case APNetworkStatus.INACTIVE:
return LL.INACTIVE(0);
case APNetworkStatus.LINGERING:
return 'Lingering until idle';
default:
return LL.UNKNOWN();
}
};
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
}
if (!data) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
return (
<SectionContent>
<List>
<ListItem>
<ListItemAvatar>
@@ -72,19 +78,26 @@ const APStatus = () => {
<SettingsInputAntennaIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.STATUS_OF('')} secondary={apStatus(data)} />
<ListItemText
primary={LL.STATUS_OF('')}
secondary={getApStatusText(data.status, LL)}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>IP</Avatar>
<Avatar sx={{ bgcolor: 'primary.main' }}>IP</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.ADDRESS_OF('IP')} secondary={data.ip_address} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<Avatar sx={{ bgcolor: 'primary.main' }}>
<DeviceHubIcon />
</Avatar>
</ListItemAvatar>
@@ -93,21 +106,22 @@ const APStatus = () => {
secondary={data.mac_address}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<Avatar sx={{ bgcolor: 'primary.main' }}>
<ComputerIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.AP_CLIENTS()} secondary={data.station_num} />
</ListItem>
<Divider variant="inset" component="li" />
</List>
);
};
return <SectionContent>{content()}</SectionContent>;
</SectionContent>
);
};
export default APStatus;

View File

@@ -1,3 +1,5 @@
import { useCallback, useMemo } from 'react';
import {
Body,
Cell,
@@ -17,6 +19,12 @@ import { useInterval } from 'utils';
import { readActivity } from '../../api/app';
import type { Stat } from '../main/types';
const QUALITY_COLORS = {
PERFECT: '#00FF7F',
WARNING: 'orange',
POOR: 'red'
} as const;
const SystemActivity = () => {
const { data, send: loadData, error } = useRequest(readActivity);
@@ -28,14 +36,16 @@ const SystemActivity = () => {
useLayoutTitle(LL.DATA_TRAFFIC());
const stats_theme = tableTheme({
Table: `
const stats_theme = tableTheme(
useMemo(
() => ({
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 90px 90px 80px;
`,
BaseRow: `
BaseRow: `
font-size: 14px;
`,
HeaderRow: `
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
@@ -45,7 +55,7 @@ const SystemActivity = () => {
border-bottom: 1px solid #565656;
}
`,
Row: `
Row: `
.td {
padding: 8px;
border-top: 1px solid #565656;
@@ -59,35 +69,42 @@ const SystemActivity = () => {
background-color: #1e1e1e;
}
`,
BaseCell: `
BaseCell: `
&:not(:first-of-type) {
text-align: center;
}
`
});
}),
[]
)
);
const showName = (id: number) => {
const name: keyof Translation['STATUS_NAMES'] = id;
return LL.STATUS_NAMES[name]();
};
const showName = useCallback(
(id: number) => {
const name: keyof Translation['STATUS_NAMES'] =
id.toString() as keyof Translation['STATUS_NAMES'];
return LL.STATUS_NAMES[name]();
},
[LL]
);
const showQuality = (stat: Stat) => {
const showQuality = useCallback((stat: Stat) => {
if (stat.q === 0 || stat.s + stat.f === 0) {
return;
}
if (stat.q === 100) {
return <div style={{ color: '#00FF7F' }}>{stat.q}%</div>;
return <div style={{ color: QUALITY_COLORS.PERFECT }}>{stat.q}%</div>;
}
if (stat.q >= 95) {
return <div style={{ color: 'orange' }}>{stat.q}%</div>;
return <div style={{ color: QUALITY_COLORS.WARNING }}>{stat.q}%</div>;
} else {
return <div style={{ color: 'red' }}>{stat.q}%</div>;
return <div style={{ color: QUALITY_COLORS.POOR }}>{stat.q}%</div>;
}
};
}, []);
const content = () => {
const content = useMemo(() => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
return (
@@ -120,9 +137,9 @@ const SystemActivity = () => {
)}
</Table>
);
};
}, [data, loadData, error?.message, stats_theme, LL, showName, showQuality]);
return <SectionContent>{content()}</SectionContent>;
return <SectionContent>{content}</SectionContent>;
};
export default SystemActivity;

View File

@@ -1,3 +1,5 @@
import { ReactElement } from 'react';
import AppsIcon from '@mui/icons-material/Apps';
import DeveloperBoardIcon from '@mui/icons-material/DeveloperBoard';
import DevicesIcon from '@mui/icons-material/Devices';
@@ -24,10 +26,61 @@ import { useInterval } from 'utils';
import BBQKeesIcon from './bbqkees.svg';
// Constants
const AVATAR_COLORS = {
DEFAULT: '#5f9a5f',
BBQKEES: '#003289'
} as const;
const TEMP_THRESHOLD_CELSIUS = 90; // Temperature threshold to determine F vs C
function formatNumber(num: number) {
return new Intl.NumberFormat().format(num);
}
function formatTemperature(temp?: number): string {
if (!temp) return '';
const unit = temp > TEMP_THRESHOLD_CELSIUS ? 'F' : 'C';
return `, T: ${temp} °${unit}`;
}
function formatFlashSpeed(speed: number): string {
return (speed / 1000000).toFixed(0) + ' MHz';
}
function formatCPUCores(cores: number): string {
return cores === 1 ? 'single-core)' : 'dual-core)';
}
// Reusable component for hardware status list items
interface HardwareListItemProps {
icon: ReactElement;
primary: string;
secondary: string;
avatarColor?: string;
customIcon?: ReactElement | undefined;
}
const HardwareListItem = ({
icon,
primary,
secondary,
avatarColor = AVATAR_COLORS.DEFAULT,
customIcon
}: HardwareListItemProps) => (
<>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: avatarColor, color: 'white' }}>
{customIcon || icon}
</Avatar>
</ListItemAvatar>
<ListItemText primary={primary} secondary={secondary} />
</ListItem>
<Divider variant="inset" component="li" />
</>
);
const HardwareStatus = () => {
const { LL } = useI18nContext();
@@ -39,175 +92,72 @@ const HardwareStatus = () => {
void loadData();
});
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
}
if (!data) {
return (
<List>
<ListItem>
<ListItemAvatar>
{data.model ? (
<Avatar sx={{ bgcolor: '#003289', color: 'white' }}>
<img
alt="BBQKees"
src={BBQKeesIcon}
style={{ width: 16, verticalAlign: 'middle' }}
/>
</Avatar>
) : (
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<TapAndPlayIcon />
</Avatar>
)}
</ListItemAvatar>
<ListItemText
primary={LL.HARDWARE() + ' ' + LL.DEVICE()}
secondary={data.model ? data.model : data.cpu_type}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<DevicesIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary="SDK"
secondary={data.arduino_version + ' / ESP-IDF ' + data.sdk_version}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<DeveloperBoardIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary="CPU"
secondary={
data.esp_platform +
'/' +
data.cpu_type +
' (rev.' +
data.cpu_rev +
', ' +
(data.cpu_cores === 1 ? 'single-core)' : 'dual-core)') +
' @ ' +
data.cpu_freq_mhz +
' Mhz' +
// bit of a hack : if the CPU temp is higher than 90 (=32 Fahrenheit if using Celsius), show F, otherwise C
(data.temperature
? ', T: ' +
data.temperature +
' °' +
(data.temperature > 90 ? 'F' : 'C')
: '')
}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<MemoryIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.FREE_MEMORY()}
secondary={
formatNumber(data.free_heap) +
' KB (' +
formatNumber(data.max_alloc_heap) +
' KB max alloc, ' +
formatNumber(data.free_caps) +
' KB caps)'
}
/>
</ListItem>
{data.psram_size !== undefined && data.free_psram !== undefined && (
<>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<AppsIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.PSRAM()}
secondary={
formatNumber(data.psram_size) +
' KB / ' +
formatNumber(data.free_psram) +
' KB'
}
/>
</ListItem>
</>
)}
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<SdStorageIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.FLASH()}
secondary={
formatNumber(data.flash_chip_size) +
' KB , ' +
(data.flash_chip_speed / 1000000).toFixed(0) +
' MHz'
}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<SdCardAlertIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.APPSIZE()}
secondary={
data.partition +
': ' +
formatNumber(data.app_used) +
' KB / ' +
formatNumber(data.app_free) +
' KB'
}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<FolderIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.FILESYSTEM()}
secondary={
formatNumber(data.fs_used) +
' KB / ' +
formatNumber(data.fs_free) +
' KB'
}
/>
</ListItem>
<Divider variant="inset" component="li" />
</List>
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
};
}
return <SectionContent>{content()}</SectionContent>;
return (
<SectionContent>
<List>
<HardwareListItem
icon={<TapAndPlayIcon />}
primary={`${LL.HARDWARE()} ${LL.DEVICE()}`}
secondary={data.model || data.cpu_type}
avatarColor={data.model ? AVATAR_COLORS.BBQKEES : AVATAR_COLORS.DEFAULT}
customIcon={
data.model ? (
<img
alt="BBQKees"
src={BBQKeesIcon}
style={{ width: 16, verticalAlign: 'middle' }}
/>
) : undefined
}
/>
<HardwareListItem
icon={<DevicesIcon />}
primary="SDK"
secondary={`${data.arduino_version} / ESP-IDF ${data.sdk_version}`}
/>
<HardwareListItem
icon={<DeveloperBoardIcon />}
primary="CPU"
secondary={`${data.esp_platform}/${data.cpu_type} (rev.${data.cpu_rev}, ${formatCPUCores(data.cpu_cores)} @ ${data.cpu_freq_mhz} Mhz${formatTemperature(data.temperature)}`}
/>
<HardwareListItem
icon={<MemoryIcon />}
primary={LL.FREE_MEMORY()}
secondary={`${formatNumber(data.free_heap)} KB (${formatNumber(data.max_alloc_heap)} KB max alloc, ${formatNumber(data.free_caps)} KB caps)`}
/>
{data.psram_size !== undefined && data.free_psram !== undefined && (
<HardwareListItem
icon={<AppsIcon />}
primary={LL.PSRAM()}
secondary={`${formatNumber(data.psram_size)} KB / ${formatNumber(data.free_psram)} KB`}
/>
)}
<HardwareListItem
icon={<SdStorageIcon />}
primary={LL.FLASH()}
secondary={`${formatNumber(data.flash_chip_size)} KB , ${formatFlashSpeed(data.flash_chip_speed)}`}
/>
<HardwareListItem
icon={<SdCardAlertIcon />}
primary={LL.APPSIZE()}
secondary={`${data.partition}: ${formatNumber(data.app_used)} KB / ${formatNumber(data.app_free)} KB`}
/>
<HardwareListItem
icon={<FolderIcon />}
primary={LL.FILESYSTEM()}
secondary={`${formatNumber(data.fs_used)} KB / ${formatNumber(data.fs_free)} KB`}
/>
</List>
</SectionContent>
);
};
export default HardwareStatus;

View File

@@ -1,3 +1,5 @@
import { type FC, memo, useMemo } from 'react';
import AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import ReportIcon from '@mui/icons-material/Report';
@@ -22,17 +24,28 @@ import type { MqttStatusType } from 'types';
import { MqttDisconnectReason } from 'types';
import { useInterval } from 'utils';
// Disconnect reason lookup table - created once, reused across renders
const DISCONNECT_REASONS: Record<MqttDisconnectReason, string> = {
[MqttDisconnectReason.USER_OK]: 'User disconnected',
[MqttDisconnectReason.TCP_DISCONNECTED]: 'TCP disconnected',
[MqttDisconnectReason.MQTT_UNACCEPTABLE_PROTOCOL_VERSION]:
'Unacceptable protocol version',
[MqttDisconnectReason.MQTT_IDENTIFIER_REJECTED]: 'Client ID rejected',
[MqttDisconnectReason.MQTT_SERVER_UNAVAILABLE]: 'Server unavailable',
[MqttDisconnectReason.MQTT_MALFORMED_CREDENTIALS]: 'Malformed credentials',
[MqttDisconnectReason.MQTT_NOT_AUTHORIZED]: 'Not authorized',
[MqttDisconnectReason.TLS_BAD_FINGERPRINT]: 'TLS fingerprint invalid'
};
const getDisconnectReason = (disconnect_reason: MqttDisconnectReason): string =>
DISCONNECT_REASONS[disconnect_reason] ?? 'Unknown';
export const mqttStatusHighlight = (
{ enabled, connected }: MqttStatusType,
theme: Theme
) => {
if (!enabled) {
return theme.palette.info.main;
}
if (connected) {
return theme.palette.success.main;
}
return theme.palette.error.main;
if (!enabled) return theme.palette.info.main;
return connected ? theme.palette.success.main : theme.palette.error.main;
};
export const mqttPublishHighlight = (
@@ -41,114 +54,100 @@ export const mqttPublishHighlight = (
) => {
if (mqtt_fails === 0) return theme.palette.success.main;
if (mqtt_fails < 10) return theme.palette.warning.main;
return theme.palette.error.main;
};
export const mqttQueueHighlight = (
{ mqtt_queued }: MqttStatusType,
theme: Theme
) => {
if (mqtt_queued <= 1) return theme.palette.success.main;
export const mqttQueueHighlight = ({ mqtt_queued }: MqttStatusType, theme: Theme) =>
mqtt_queued <= 1 ? theme.palette.success.main : theme.palette.warning.main;
return theme.palette.warning.main;
};
interface ConnectionStatusProps {
data: MqttStatusType;
theme: Theme;
}
// Memoized component to prevent unnecessary re-renders when parent updates
const ConnectionStatus: FC<ConnectionStatusProps> = memo(({ data, theme }) => {
const { LL } = useI18nContext();
return (
<>
{!data.connected && (
<>
<ListItem>
<ListItemAvatar>
<Avatar>
<ReportIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.DISCONNECT_REASON()}
secondary={getDisconnectReason(data.disconnect_reason)}
/>
</ListItem>
<Divider variant="inset" component="li" />
</>
)}
<ListItem>
<ListItemAvatar>
<Avatar>#</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.ID_OF(LL.CLIENT())} secondary={data.client_id} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: mqttQueueHighlight(data, theme) }}>
<AutoAwesomeMotionIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.MQTT_QUEUE()} secondary={data.mqtt_queued} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: mqttPublishHighlight(data, theme) }}>
<SpeakerNotesOffIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.ERRORS_OF('MQTT')} secondary={data.mqtt_fails} />
</ListItem>
<Divider variant="inset" component="li" />
</>
);
});
const MqttStatus = () => {
const { data, send: loadData, error } = useRequest(MqttApi.readMqttStatus);
const { LL } = useI18nContext();
const theme = useTheme();
useLayoutTitle('MQTT');
useInterval(() => {
void loadData();
});
const { LL } = useI18nContext();
useLayoutTitle('MQTT');
// Memoize error message separately to avoid re-renders on error object changes
const errorMessage = error?.message || '';
const theme = useTheme();
const mqttStatus = ({ enabled, connected, connect_count }: MqttStatusType) => {
if (!enabled) {
return LL.NOT_ENABLED();
}
if (connected) {
return LL.CONNECTED(0) + ' (' + connect_count + ')';
}
return LL.DISCONNECTED() + ' (' + connect_count + ')';
};
const disconnectReason = ({ disconnect_reason }: MqttStatusType) => {
switch (disconnect_reason) {
case MqttDisconnectReason.TCP_DISCONNECTED:
return 'TCP disconnected';
case MqttDisconnectReason.MQTT_UNACCEPTABLE_PROTOCOL_VERSION:
return 'Unacceptable protocol version';
case MqttDisconnectReason.MQTT_IDENTIFIER_REJECTED:
return 'Client ID rejected';
case MqttDisconnectReason.MQTT_SERVER_UNAVAILABLE:
return 'Server unavailable';
case MqttDisconnectReason.MQTT_MALFORMED_CREDENTIALS:
return 'Malformed credentials';
case MqttDisconnectReason.MQTT_NOT_AUTHORIZED:
return 'Not authorized';
case MqttDisconnectReason.TLS_BAD_FINGERPRINT:
return 'TLS fingerprint invalid';
default:
return 'Unknown';
}
};
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
}
const renderConnectionStatus = () => (
<>
{!data.connected && (
<>
<ListItem>
<ListItemAvatar>
<Avatar>
<ReportIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.DISCONNECT_REASON()}
secondary={disconnectReason(data)}
/>
</ListItem>
<Divider variant="inset" component="li" />
</>
)}
<ListItem>
<ListItemAvatar>
<Avatar>#</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.ID_OF(LL.CLIENT())} secondary={data.client_id} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: mqttQueueHighlight(data, theme) }}>
<AutoAwesomeMotionIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.MQTT_QUEUE()} secondary={data.mqtt_queued} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: mqttPublishHighlight(data, theme) }}>
<SpeakerNotesOffIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.ERRORS_OF('MQTT')} secondary={data.mqtt_fails} />
</ListItem>
<Divider variant="inset" component="li" />
</>
);
const mqttStatusText = useMemo(() => {
if (!data) return '';
if (!data.enabled) return LL.NOT_ENABLED();
return data.connected
? `${LL.CONNECTED(0)} (${data.connect_count})`
: `${LL.DISCONNECTED()} (${data.connect_count})`;
}, [data, LL]);
if (!data) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={errorMessage} />
</SectionContent>
);
}
return (
<SectionContent>
<List>
<ListItem>
<ListItemAvatar>
@@ -156,15 +155,13 @@ const MqttStatus = () => {
<DeviceHubIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.STATUS_OF('')} secondary={mqttStatus(data)} />
<ListItemText primary={LL.STATUS_OF('')} secondary={mqttStatusText} />
</ListItem>
<Divider variant="inset" component="li" />
{data.enabled && renderConnectionStatus()}
{data.enabled && <ConnectionStatus data={data} theme={theme} />}
</List>
);
};
return <SectionContent>{content()}</SectionContent>;
</SectionContent>
);
};
export default MqttStatus;

View File

@@ -1,40 +1,46 @@
import { useState } from 'react';
import { toast } from 'react-toastify';
import { useMemo } from 'react';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CancelIcon from '@mui/icons-material/Cancel';
import DnsIcon from '@mui/icons-material/Dns';
import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle';
import UpdateIcon from '@mui/icons-material/Update';
import {
Avatar,
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
List,
ListItem,
ListItemAvatar,
ListItemText,
TextField,
Typography,
useTheme
} from '@mui/material';
import type { Theme } from '@mui/material';
import * as NTPApi from 'api/ntp';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import { ButtonRow, FormLoader, SectionContent, useLayoutTitle } from 'components';
import { FormLoader, SectionContent, useLayoutTitle } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import type { NTPStatusType, Time } from 'types';
import type { NTPStatusType } from 'types';
import { NTPSyncStatus } from 'types';
import { useInterval } from 'utils';
import { formatDateTime, formatLocalDateTime } from 'utils';
import { formatDateTime } from 'utils';
// Utility functions
const isNtpEnabled = ({ status }: NTPStatusType) =>
status !== NTPSyncStatus.NTP_DISABLED;
const ntpStatusHighlight = ({ status }: NTPStatusType, theme: Theme) => {
switch (status) {
case NTPSyncStatus.NTP_DISABLED:
return theme.palette.info.main;
case NTPSyncStatus.NTP_INACTIVE:
return theme.palette.error.main;
case NTPSyncStatus.NTP_ACTIVE:
return theme.palette.success.main;
default:
return theme.palette.error.main;
}
};
const NTPStatus = () => {
const { data, send: loadData, error } = useRequest(NTPApi.readNTPStatus);
@@ -43,48 +49,9 @@ const NTPStatus = () => {
void loadData();
});
const [localTime, setLocalTime] = useState<string>('');
const [settingTime, setSettingTime] = useState<boolean>(false);
const [processing, setProcessing] = useState<boolean>(false);
const { LL } = useI18nContext();
useLayoutTitle('NTP');
const { send: updateTime } = useRequest(
(local_time: Time) => NTPApi.updateTime(local_time),
{
immediate: false
}
);
NTPApi.updateTime;
const isNtpActive = ({ status }: NTPStatusType) =>
status === NTPSyncStatus.NTP_ACTIVE;
const isNtpEnabled = ({ status }: NTPStatusType) =>
status !== NTPSyncStatus.NTP_DISABLED;
const ntpStatusHighlight = ({ status }: NTPStatusType, theme: Theme) => {
switch (status) {
case NTPSyncStatus.NTP_DISABLED:
return theme.palette.info.main;
case NTPSyncStatus.NTP_INACTIVE:
return theme.palette.error.main;
case NTPSyncStatus.NTP_ACTIVE:
return theme.palette.success.main;
default:
return theme.palette.error.main;
}
};
const updateLocalTime = (event: React.ChangeEvent<HTMLInputElement>) =>
setLocalTime(event.target.value);
const openSetTime = () => {
setLocalTime(formatLocalDateTime(new Date()));
setSettingTime(true);
};
const theme = useTheme();
const ntpStatus = ({ status }: NTPStatusType) => {
@@ -100,147 +67,64 @@ const NTPStatus = () => {
}
};
const configureTime = async () => {
setProcessing(true);
await updateTime({ local_time: formatLocalDateTime(new Date(localTime)) })
.then(async () => {
toast.success(LL.TIME_SET());
setSettingTime(false);
await loadData();
})
.catch(() => {
toast.error(LL.PROBLEM_UPDATING());
})
.finally(() => {
setProcessing(false);
});
};
const renderSetTimeDialog = () => (
<Dialog
sx={dialogStyle}
open={settingTime}
onClose={() => setSettingTime(false)}
>
<DialogTitle>{LL.SET_TIME(1)}</DialogTitle>
<DialogContent dividers>
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
<Typography variant="body2">{LL.SET_TIME_TEXT()}</Typography>
</Box>
<TextField
label={LL.LOCAL_TIME(0)}
type="datetime-local"
value={localTime}
onChange={updateLocalTime}
disabled={processing}
fullWidth
slotProps={{
inputLabel: {
shrink: true
}
}}
/>
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={() => setSettingTime(false)}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<AccessTimeIcon />}
variant="outlined"
onClick={configureTime}
disabled={processing}
color="primary"
>
{LL.UPDATE()}
</Button>
</DialogActions>
</Dialog>
);
const content = () => {
const content = useMemo(() => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
return (
<>
<List>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: ntpStatusHighlight(data, theme) }}>
<UpdateIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.STATUS_OF('')} secondary={ntpStatus(data)} />
</ListItem>
<Divider variant="inset" component="li" />
{isNtpEnabled(data) && (
<>
<ListItem>
<ListItemAvatar>
<Avatar>
<DnsIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.NTP_SERVER()} secondary={data.server} />
</ListItem>
<Divider variant="inset" component="li" />
</>
)}
<ListItem>
<ListItemAvatar>
<Avatar>
<AccessTimeIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.LOCAL_TIME(0)}
secondary={formatDateTime(data.local_time)}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<SwapVerticalCircleIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.UTC_TIME()}
secondary={formatDateTime(data.utc_time)}
/>
</ListItem>
<Divider variant="inset" component="li" />
</List>
<Box display="flex" flexWrap="wrap">
{data && !isNtpActive(data) && (
<Box flexWrap="nowrap" whiteSpace="nowrap">
<ButtonRow>
<Button
onClick={openSetTime}
variant="outlined"
color="primary"
startIcon={<AccessTimeIcon />}
>
{LL.SET_TIME(0)}
</Button>
</ButtonRow>
</Box>
)}
</Box>
{renderSetTimeDialog()}
</>
<List>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: ntpStatusHighlight(data, theme) }}>
<UpdateIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.STATUS_OF('')} secondary={ntpStatus(data)} />
</ListItem>
<Divider variant="inset" component="li" />
{isNtpEnabled(data) && (
<>
<ListItem>
<ListItemAvatar>
<Avatar>
<DnsIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.NTP_SERVER()} secondary={data.server} />
</ListItem>
<Divider variant="inset" component="li" />
</>
)}
<ListItem>
<ListItemAvatar>
<Avatar>
<AccessTimeIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.LOCAL_TIME(0)}
secondary={formatDateTime(data.local_time)}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<SwapVerticalCircleIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.UTC_TIME()}
secondary={formatDateTime(data.utc_time)}
/>
</ListItem>
<Divider variant="inset" component="li" />
</List>
);
};
}, [data, error, loadData, LL, theme]);
return <SectionContent>{content()}</SectionContent>;
return <SectionContent>{content}</SectionContent>;
};
export default NTPStatus;

View File

@@ -1,3 +1,5 @@
import { useMemo } from 'react';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import DnsIcon from '@mui/icons-material/Dns';
import GiteIcon from '@mui/icons-material/Gite';
@@ -25,10 +27,17 @@ import type { NetworkStatusType } from 'types';
import { NetworkConnectionStatus } from 'types';
import { useInterval } from 'utils';
// Utility functions
const isConnected = ({ status }: NetworkStatusType) =>
status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED ||
status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED;
export const isWiFi = ({ status }: NetworkStatusType) =>
status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED;
export const isEthernet = ({ status }: NetworkStatusType) =>
status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED;
const networkStatusHighlight = ({ status }: NetworkStatusType, theme: Theme) => {
switch (status) {
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
@@ -55,11 +64,6 @@ const networkQualityHighlight = ({ rssi }: NetworkStatusType, theme: Theme) => {
return theme.palette.success.main;
};
export const isWiFi = ({ status }: NetworkStatusType) =>
status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED;
export const isEthernet = ({ status }: NetworkStatusType) =>
status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED;
const dnsServers = ({ dns_ip_1, dns_ip_2 }: NetworkStatusType) => {
if (!dns_ip_1) {
return 'none';
@@ -81,6 +85,33 @@ const IPs = (status: NetworkStatusType) => {
return status.local_ip + ', ' + status.local_ipv6;
};
const getNetworkStatusText = (
status: NetworkConnectionStatus,
reconnectCount: number,
LL: ReturnType<typeof useI18nContext>['LL']
) => {
switch (status) {
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (Ethernet)';
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return LL.INACTIVE(1);
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
return LL.IDLE();
case NetworkConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL:
return 'No SSID Available';
case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (WiFi) (' + reconnectCount + ')';
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
return LL.CONNECTED(1) + ' ' + LL.FAILED(0) + ' (' + reconnectCount + ')';
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
return LL.CONNECTED(1) + ' ' + LL.LOST() + ' (' + reconnectCount + ')';
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
return LL.DISCONNECTED();
default:
return LL.UNKNOWN();
}
};
const NetworkStatus = () => {
const { data, send: loadData, error } = useRequest(NetworkApi.readNetworkStatus);
@@ -93,51 +124,30 @@ const NetworkStatus = () => {
const theme = useTheme();
const networkStatus = ({ status }: NetworkStatusType) => {
switch (status) {
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (Ethernet)';
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return LL.INACTIVE(1);
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
return LL.IDLE();
case NetworkConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL:
return 'No SSID Available';
case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (WiFi) (' + data.reconnect_count + ')';
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
return (
LL.CONNECTED(1) + ' ' + LL.FAILED(0) + ' (' + data.reconnect_count + ')'
);
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
return LL.CONNECTED(1) + ' ' + LL.LOST() + ' (' + data.reconnect_count + ')';
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
return LL.DISCONNECTED();
default:
return LL.UNKNOWN();
}
};
const content = () => {
const content = useMemo(() => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
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 (
<List>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: networkStatusHighlight(data, theme) }}>
<Avatar sx={{ bgcolor: statusColor }}>
{isWiFi(data) && <WifiIcon />}
{isEthernet(data) && <RouterIcon />}
</Avatar>
</ListItemAvatar>
<ListItemText primary="Status" secondary={networkStatus(data)} />
<ListItemText primary="Status" secondary={statusText} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: networkStatusHighlight(data, theme) }}>
<Avatar sx={{ bgcolor: statusColor }}>
<GiteIcon />
</Avatar>
</ListItemAvatar>
@@ -148,13 +158,13 @@ const NetworkStatus = () => {
<>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: networkQualityHighlight(data, theme) }}>
<Avatar sx={{ bgcolor: qualityColor }}>
<SettingsInputAntennaIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary="SSID (RSSI)"
secondary={data.ssid + ' (' + data.rssi + ' dBm)'}
secondary={`${data.ssid} (${data.rssi} dBm)`}
/>
</ListItem>
<Divider variant="inset" component="li" />
@@ -218,9 +228,9 @@ const NetworkStatus = () => {
)}
</List>
);
};
}, [data, error, loadData, LL, theme]);
return <SectionContent>{content()}</SectionContent>;
return <SectionContent>{content}</SectionContent>;
};
export default NetworkStatus;

View File

@@ -1,4 +1,4 @@
import { useContext, useState } from 'react';
import { useCallback, useContext, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
@@ -8,10 +8,10 @@ import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import DirectionsBusIcon from '@mui/icons-material/DirectionsBus';
import LogoDevIcon from '@mui/icons-material/LogoDev';
import MemoryIcon from '@mui/icons-material/Memory';
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import RouterIcon from '@mui/icons-material/Router';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import TimerIcon from '@mui/icons-material/Timer';
import WifiIcon from '@mui/icons-material/Wifi';
import {
Avatar,
@@ -37,12 +37,34 @@ import { FormLoader, SectionContent, useLayoutTitle } from 'components';
import ListMenuItem from 'components/layout/ListMenuItem';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import { NTPSyncStatus, NetworkConnectionStatus } from 'types';
import { NTPSyncStatus, NetworkConnectionStatus, SystemStatusCodes } from 'types';
import { useInterval } from 'utils';
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 formatDurationSec = (
duration_sec: number,
LL: ReturnType<typeof useI18nContext>['LL']
) => {
const ms = duration_sec * 1000;
const days = Math.trunc(ms / 86400000);
const hours = Math.trunc(ms / 3600000) % 24;
const minutes = Math.trunc(ms / 60000) % 60;
const seconds = Math.trunc(ms / 1000) % 60;
const parts: string[] = [];
if (days) parts.push(LL.NUM_DAYS({ num: days }));
if (hours) parts.push(LL.NUM_HOURS({ num: hours }));
if (minutes) parts.push(LL.NUM_MINUTES({ num: minutes }));
parts.push(LL.NUM_SECONDS({ num: seconds }));
return parts.join(' ');
};
const SystemStatus = () => {
const { LL } = useI18nContext();
@@ -62,7 +84,6 @@ const SystemStatus = () => {
send: loadData,
error
} = useRequest(readSystemStatus, {
initialData: [],
async middleware(_, next) {
if (!restarting) {
await next();
@@ -76,51 +97,46 @@ const SystemStatus = () => {
const theme = useTheme();
const formatDurationSec = (duration_sec: number) => {
const days = Math.trunc((duration_sec * 1000) / 86400000);
const hours = Math.trunc((duration_sec * 1000) / 3600000) % 24;
const minutes = Math.trunc((duration_sec * 1000) / 60000) % 60;
const seconds = Math.trunc((duration_sec * 1000) / 1000) % 60;
// Memoize derived status values to avoid recalculation on every render
const busStatus = useMemo(() => {
if (!data) return 'EMS state unknown';
let formatted = '';
if (days) {
formatted += LL.NUM_DAYS({ num: days }) + ' ';
switch (data.bus_status) {
case busConnectionStatus.BUS_STATUS_CONNECTED:
return `EMS ${LL.CONNECTED(0)} (${formatDurationSec(data.bus_uptime, LL)})`;
case busConnectionStatus.BUS_STATUS_TX_ERRORS:
return 'EMS ' + LL.TX_ISSUES();
case busConnectionStatus.BUS_STATUS_OFFLINE:
return 'EMS ' + LL.DISCONNECTED();
default:
return 'EMS state unknown';
}
if (hours) {
formatted += LL.NUM_HOURS({ num: hours }) + ' ';
}
if (minutes) {
formatted += LL.NUM_MINUTES({ num: minutes }) + ' ';
}
formatted += LL.NUM_SECONDS({ num: seconds });
return formatted;
};
}, [data?.bus_status, data?.bus_uptime, LL]);
function formatNumber(num: number) {
return new Intl.NumberFormat().format(num);
}
// Memoize derived status values to avoid recalculation on every render
const systemStatus = useMemo(() => {
if (!data) return '??';
const busStatus = () => {
if (data) {
switch (data.bus_status) {
case busConnectionStatus.BUS_STATUS_CONNECTED:
return (
'EMS ' +
LL.CONNECTED(0) +
' (' +
formatDurationSec(data.bus_uptime) +
')'
);
case busConnectionStatus.BUS_STATUS_TX_ERRORS:
return 'EMS ' + LL.TX_ISSUES();
case busConnectionStatus.BUS_STATUS_OFFLINE:
return 'EMS ' + LL.DISCONNECTED();
}
switch (data.status) {
case SystemStatusCodes.SYSTEM_STATUS_PENDING_UPLOAD:
case SystemStatusCodes.SYSTEM_STATUS_UPLOADING:
return LL.WAIT_FIRMWARE();
case SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD:
return LL.ERROR();
case SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART:
case SystemStatusCodes.SYSTEM_STATUS_RESTART_REQUESTED:
return LL.RESTARTING_PRE();
case SystemStatusCodes.SYSTEM_STATUS_INVALID_GPIO:
return LL.GPIO_OF(LL.FAILED(0));
default:
// SystemStatusCodes.SYSTEM_STATUS_NORMAL
return 'OK';
}
return 'EMS state unknown';
};
}, [data?.status, LL]);
const busStatusHighlight = useMemo(() => {
if (!data) return theme.palette.warning.main;
const busStatusHighlight = () => {
switch (data.bus_status) {
case busConnectionStatus.BUS_STATUS_TX_ERRORS:
return theme.palette.warning.main;
@@ -131,27 +147,28 @@ const SystemStatus = () => {
default:
return theme.palette.warning.main;
}
};
}, [data?.bus_status, theme.palette]);
const ntpStatus = useMemo(() => {
if (!data) return LL.UNKNOWN();
const ntpStatus = () => {
switch (data.ntp_status) {
case NTPSyncStatus.NTP_DISABLED:
return LL.NOT_ENABLED();
case NTPSyncStatus.NTP_INACTIVE:
return LL.INACTIVE(0);
case NTPSyncStatus.NTP_ACTIVE:
return (
LL.ACTIVE() +
(data.ntp_time !== undefined
? ' (' + formatDateTime(data.ntp_time) + ')'
: '')
);
return data.ntp_time
? `${LL.ACTIVE()} (${formatDateTime(data.ntp_time)})`
: LL.ACTIVE();
default:
return LL.UNKNOWN();
}
};
}, [data?.ntp_status, data?.ntp_time, LL]);
const ntpStatusHighlight = useMemo(() => {
if (!data) return theme.palette.error.main;
const ntpStatusHighlight = () => {
switch (data.ntp_status) {
case NTPSyncStatus.NTP_DISABLED:
return theme.palette.info.main;
@@ -162,9 +179,11 @@ const SystemStatus = () => {
default:
return theme.palette.error.main;
}
};
}, [data?.ntp_status, theme.palette]);
const networkStatusHighlight = useMemo(() => {
if (!data) return theme.palette.warning.main;
const networkStatusHighlight = () => {
switch (data.network_status) {
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
@@ -179,9 +198,11 @@ const SystemStatus = () => {
default:
return theme.palette.warning.main;
}
};
}, [data?.network_status, theme.palette]);
const networkStatus = useMemo(() => {
if (!data) return LL.UNKNOWN();
const networkStatus = () => {
switch (data.network_status) {
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return LL.INACTIVE(1);
@@ -190,24 +211,27 @@ const SystemStatus = () => {
case NetworkConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL:
return 'No SSID Available';
case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (WiFi, ' + data.wifi_rssi + ' dBm)';
return `${LL.CONNECTED(0)} (WiFi, ${data.wifi_rssi} dBm)`;
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (Ethernet)';
return `${LL.CONNECTED(0)} (Ethernet)`;
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
return LL.CONNECTED(1) + ' ' + LL.FAILED(0);
return `${LL.CONNECTED(1)} ${LL.FAILED(0)}`;
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
return LL.CONNECTED(1) + ' ' + LL.LOST();
return `${LL.CONNECTED(1)} ${LL.LOST()}`;
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
return LL.DISCONNECTED();
default:
return LL.UNKNOWN();
}
};
}, [data?.network_status, data?.wifi_rssi, LL]);
const activeHighlight = (value: boolean) =>
value ? theme.palette.success.main : theme.palette.info.main;
const activeHighlight = useCallback(
(value: boolean) =>
value ? theme.palette.success.main : theme.palette.info.main,
[theme.palette]
);
const doRestart = async () => {
const doRestart = useCallback(async () => {
setConfirmRestart(false);
setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
@@ -215,69 +239,114 @@ const SystemStatus = () => {
toast.error(error.message);
}
);
};
}, [sendAPI]);
const renderRestartDialog = () => (
<Dialog
sx={dialogStyle}
open={confirmRestart}
onClose={() => setConfirmRestart(false)}
>
<DialogTitle>{LL.RESTART()}</DialogTitle>
<DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={() => setConfirmRestart(false)}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
onClick={doRestart}
color="error"
>
{LL.RESTART()}
</Button>
</DialogActions>
</Dialog>
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]
);
const content = () => {
// 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} />;
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
return (
<>
<List sx={{ borderRadius: 3, border: '2px solid grey' }}>
<List>
<ListMenuItem
icon={BuildIcon}
bgcolor="#72caf9"
label="EMS-ESP Firmware"
text={'v' + data.emsesp_version}
text={firmwareVersion}
to="version"
/>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}>
<TimerIcon />
<MonitorHeartIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.UPTIME()}
secondary={formatDurationSec(data.uptime)}
primary={LL.STATUS_OF(LL.SYSTEM(0))}
secondary={`${systemStatus} (${LL.UPTIME()}: ${uptimeText})`}
/>
{me.admin && (
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
color="error"
onClick={() => setConfirmRestart(true)}
onClick={handleRestartClick}
>
{LL.RESTART()}
</Button>
@@ -289,29 +358,25 @@ const SystemStatus = () => {
icon={MemoryIcon}
bgcolor="#68374d"
label={LL.HARDWARE()}
text={formatNumber(data.free_heap) + ' KB' + ' ' + LL.FREE_MEMORY()}
text={freeMemoryText}
to="/status/hardwarestatus"
/>
<ListMenuItem
disabled={!me.admin}
icon={DirectionsBusIcon}
bgcolor={busStatusHighlight()}
bgcolor={busStatusHighlight}
label={LL.DATA_TRAFFIC()}
text={busStatus()}
text={busStatus}
to="/status/activity"
/>
<ListMenuItem
disabled={!me.admin}
icon={
data.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED
? WifiIcon
: RouterIcon
}
bgcolor={networkStatusHighlight()}
icon={networkIcon}
bgcolor={networkStatusHighlight}
label={LL.NETWORK(1)}
text={networkStatus()}
text={networkStatus}
to="/status/network"
/>
@@ -320,16 +385,16 @@ const SystemStatus = () => {
icon={DeviceHubIcon}
bgcolor={activeHighlight(data.mqtt_status)}
label="MQTT"
text={data.mqtt_status ? LL.CONNECTED(0) : LL.INACTIVE(0)}
text={mqttStatusText}
to="/status/mqtt"
/>
<ListMenuItem
disabled={!me.admin}
icon={AccessTimeIcon}
bgcolor={ntpStatusHighlight()}
bgcolor={ntpStatusHighlight}
label="NTP"
text={ntpStatus()}
text={ntpStatus}
to="/status/ntp"
/>
@@ -338,7 +403,7 @@ const SystemStatus = () => {
icon={SettingsInputAntennaIcon}
bgcolor={activeHighlight(data.ap_status)}
label={LL.ACCESS_POINT(0)}
text={data.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)}
text={apStatusText}
to="/status/ap"
/>
@@ -352,14 +417,33 @@ const SystemStatus = () => {
/>
</List>
{renderRestartDialog()}
{renderRestartDialog}
</>
);
};
}, [
data,
LL,
firmwareVersion,
uptimeText,
freeMemoryText,
networkIcon,
mqttStatusText,
apStatusText,
busStatus,
busStatusHighlight,
networkStatusHighlight,
networkStatus,
ntpStatusHighlight,
ntpStatus,
activeHighlight,
me.admin,
handleRestartClick,
error,
loadData,
renderRestartDialog
]);
return (
<SectionContent>{restarting ? <SystemMonitor /> : content()}</SectionContent>
);
return <SectionContent>{restarting ? <SystemMonitor /> : content}</SectionContent>;
};
export default SystemStatus;

View File

@@ -1,4 +1,11 @@
import { useEffect, useRef, useState } from 'react';
import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState
} from 'react';
import { toast } from 'react-toastify';
import DownloadIcon from '@mui/icons-material/GetApp';
@@ -8,7 +15,7 @@ import {
Box,
Button,
Checkbox,
Grid2 as Grid,
Grid,
IconButton,
MenuItem,
TextField,
@@ -31,13 +38,16 @@ import type { LogEntry, LogSettings } from 'types';
import { LogLevel } from 'types';
import { updateValueDirty, useRest } from 'utils';
const TextColors = {
const MAX_LOG_ENTRIES = 1000; // Limit log entries to prevent memory issues
const TextColors: Record<LogLevel, string> = {
[LogLevel.ERROR]: '#ff0000', // red
[LogLevel.WARNING]: '#ff0000', // red
[LogLevel.NOTICE]: '#ffffff', // white
[LogLevel.INFO]: '#ffcc00', // yellow
[LogLevel.DEBUG]: '#00ffff', // cyan
[LogLevel.TRACE]: '#00ffff' // cyan
[LogLevel.TRACE]: '#00ffff', // cyan
[LogLevel.ALL]: '#ffffff' // white
};
const LogEntryLine = styled('span')(
@@ -46,11 +56,6 @@ const LogEntryLine = styled('span')(
})
);
const topOffset = () =>
document.getElementById('log-window')?.getBoundingClientRect().bottom || 0;
const leftOffset = () =>
document.getElementById('log-window')?.getBoundingClientRect().left || 0;
const levelLabel = (level: LogLevel) => {
switch (level) {
case LogLevel.ERROR:
@@ -70,6 +75,39 @@ const levelLabel = (level: LogLevel) => {
}
};
const paddedLevelLabel = (level: LogLevel, compact: boolean) => {
const label = levelLabel(level);
return compact ? ' ' + label[0] : label.padStart(8, '\xa0');
};
const paddedNameLabel = (name: string, compact: boolean) => {
const label = '[' + name + ']';
return compact ? label : label.padEnd(12, '\xa0');
};
const paddedIDLabel = (id: number, compact: boolean) => {
const label = id + ':';
return compact ? label : label.padEnd(7, '\xa0');
};
// Memoized log entry component to prevent unnecessary re-renders
const LogEntryItem = memo(
({ entry, compact }: { entry: LogEntry; compact: boolean }) => {
return (
<div style={{ font: '13px monospace', whiteSpace: 'nowrap' }}>
<span>{entry.t}</span>
<span>{paddedLevelLabel(entry.l, compact)}&nbsp;</span>
<span>{paddedIDLabel(entry.i, compact)} </span>
<span>{paddedNameLabel(entry.n, compact)} </span>
<LogEntryLine details={{ level: entry.l }}>{entry.m}</LogEntryLine>
</div>
);
},
(prevProps, nextProps) =>
prevProps.entry.i === nextProps.entry.i &&
prevProps.compact === nextProps.compact
);
const SystemLog = () => {
const { LL } = useI18nContext();
@@ -101,54 +139,89 @@ const SystemLog = () => {
const [readOpen, setReadOpen] = useState(false);
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
const [autoscroll, setAutoscroll] = useState(true);
const [lastId, setLastId] = useState<number>(-1);
const [boxPosition, setBoxPosition] = useState({ top: 0, left: 0 });
const ALPHA_NUMERIC_DASH_REGEX = /^[a-fA-F0-9 ]+$/;
const updateFormValue = updateValueDirty(
origData,
origData as unknown as Record<string, unknown>,
dirtyFlags,
setDirtyFlags,
updateDataValue
updateDataValue as (value: unknown) => void
);
// Calculate box position after layout
useLayoutEffect(() => {
const logWindow = document.getElementById('log-window');
if (!logWindow) {
return;
}
const updatePosition = () => {
const windowElement = document.getElementById('log-window');
if (!windowElement) {
return;
}
const rect = windowElement.getBoundingClientRect();
setBoxPosition({ top: rect.bottom, left: rect.left });
};
updatePosition();
// Debounce resize events with requestAnimationFrame
let rafId: number;
const handleResize = () => {
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(updatePosition);
};
// Update position on window resize
window.addEventListener('resize', handleResize);
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(logWindow);
return () => {
window.removeEventListener('resize', handleResize);
resizeObserver.disconnect();
cancelAnimationFrame(rafId);
};
}, [data]); // Recalculate when data changes (in case layout shifts)
// Memoize message handler to avoid recreating on every render
const handleLogMessage = useCallback((message: { data: string }) => {
const rawData = message.data;
const logentry = JSON.parse(rawData) as LogEntry;
setLogEntries((log) => {
// Skip if this is a duplicate entry (check last entry id)
if (log.length > 0) {
const lastEntry = log[log.length - 1];
if (lastEntry && logentry.i <= lastEntry.i) {
return log;
}
}
const newLog = [...log, logentry];
// Limit log entries to prevent memory issues - only slice when necessary
if (newLog.length > MAX_LOG_ENTRIES) {
return newLog.slice(-MAX_LOG_ENTRIES);
}
return newLog;
});
}, []);
useSSE(fetchLogES, {
immediate: true,
interceptByGlobalResponded: false
})
.onMessage((message: { data: string }) => {
const rawData = message.data;
const logentry = JSON.parse(rawData) as LogEntry;
if (lastId < logentry.i) {
setLogEntries((log) => [...log, logentry]);
setLastId(logentry.i);
}
})
.onMessage(handleLogMessage)
.onError(() => {
toast.error('No connection to Log service');
});
const paddedLevelLabel = (level: LogLevel) => {
const label = levelLabel(level);
return data?.compact ? ' ' + label[0] : label.padStart(8, '\xa0');
};
const onDownload = useCallback(() => {
const result = logEntries
.map((i) => `${i.t} ${levelLabel(i.l)} ${i.i}: [${i.n}] ${i.m}`)
.join('\n');
const paddedNameLabel = (name: string) => {
const label = '[' + name + ']';
return data?.compact ? label : label.padEnd(12, '\xa0');
};
const paddedIDLabel = (id: number) => {
const label = id + ':';
return data?.compact ? label : label.padEnd(7, '\xa0');
};
const onDownload = () => {
let result = '';
for (const i of logEntries) {
result +=
i.t + ' ' + levelLabel(i.l) + ' ' + i.i + ': [' + i.n + '] ' + i.m + '\n';
}
const a = document.createElement('a');
a.setAttribute(
'href',
@@ -158,24 +231,28 @@ const SystemLog = () => {
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
}, [logEntries]);
const saveSettings = async () => {
const saveSettings = useCallback(async () => {
await saveData();
};
}, [saveData]);
// handle scrolling
// handle scrolling - optimized to only scroll when needed
const ref = useRef<HTMLDivElement>(null);
const logWindowRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (logEntries.length && autoscroll) {
ref.current?.scrollIntoView({
behavior: 'smooth',
block: 'end'
});
const container = logWindowRef.current;
if (container) {
requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight;
});
}
}
}, [logEntries.length]);
}, [logEntries.length, autoscroll]);
const sendReadCommand = () => {
const sendReadCommand = useCallback(() => {
if (readValue === '') {
setReadOpen(!readOpen);
return;
@@ -186,11 +263,11 @@ const SystemLog = () => {
setReadOpen(false);
setReadValue('');
}
};
}, [readValue, readOpen, send]);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
return (
@@ -278,6 +355,7 @@ const SystemLog = () => {
>
<IconButton
disableRipple
aria-label={LL.CANCEL()}
onClick={() => {
setReadOpen(false);
setReadValue('');
@@ -303,7 +381,7 @@ const SystemLog = () => {
) : (
<>
{data.developer_mode && (
<IconButton onClick={sendReadCommand}>
<IconButton onClick={sendReadCommand} aria-label={LL.EXECUTE()}>
<PlayArrowIcon color="primary" />
</IconButton>
)}
@@ -325,27 +403,20 @@ const SystemLog = () => {
</Grid>
<Box
ref={logWindowRef}
sx={{
backgroundColor: 'black',
overflowY: 'scroll',
position: 'absolute',
right: 18,
bottom: 18,
left: () => leftOffset(),
top: () => topOffset(),
left: boxPosition.left,
top: boxPosition.top,
p: 1
}}
>
{logEntries.map((e) => (
<div key={e.i} style={{ font: '14px monospace', whiteSpace: 'nowrap' }}>
<span>{e.t}</span>
<span>{paddedLevelLabel(e.l)}&nbsp;</span>
<span>{paddedIDLabel(e.i)} </span>
<span>{paddedNameLabel(e.n)} </span>
<LogEntryLine details={{ level: e.l }} key={e.i}>
{e.m}
</LogEntryLine>
</div>
<LogEntryItem key={e.i} entry={e} compact={data.compact} />
))}
<div ref={ref} />

View File

@@ -1,12 +1,11 @@
import { useState } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import { Box, Button, Dialog, DialogContent, Typography } from '@mui/material';
import { Box, Button, Typography } from '@mui/material';
import { callAction } from 'api/app';
import { readSystemStatus } from 'api/system';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import MessageBox from 'components/MessageBox';
import { useI18nContext } from 'i18n/i18n-react';
@@ -17,11 +16,9 @@ import { LinearProgressWithLabel } from '../../components/upload/LinearProgressW
const SystemMonitor = () => {
const [errorMessage, setErrorMessage] = useState<string>();
const hasInitialized = useRef(false);
const { LL } = useI18nContext();
let count = 0;
const { send: setSystemStatus } = useRequest(
(status: string) => callAction({ action: 'systemStatus', param: status }),
{
@@ -32,10 +29,12 @@ const SystemMonitor = () => {
const { data, send } = useRequest(readSystemStatus, {
force: true,
async middleware(_, next) {
if (count++ >= 1) {
// skip first request (1 second) to allow AsyncWS to send its response
await next();
// Skip first request to allow AsyncWS to send its response
if (!hasInitialized.current) {
hasInitialized.current = true;
return; // Don't await next() on first call
}
await next();
}
})
.onSuccess((event) => {
@@ -51,53 +50,102 @@ const SystemMonitor = () => {
}
})
.onError((error) => {
setErrorMessage(error.message);
setErrorMessage(String(error.error?.message || 'An error occurred'));
});
useInterval(() => {
void send();
}, 1000); // check every 1 second
const onCancel = async () => {
const { statusMessage, isUploading, progressValue } = useMemo(() => {
const status = data?.status;
let message = '';
if (status && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING) {
message = LL.WAIT_FIRMWARE();
} else if (status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART) {
message = LL.APPLICATION_RESTARTING();
} else if (status === SystemStatusCodes.SYSTEM_STATUS_NORMAL) {
message = LL.RESTARTING_PRE();
} else if (status === SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD) {
message = 'Upload Failed';
} else {
message = LL.RESTARTING_POST();
}
const uploading =
status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING;
const progress =
uploading && status
? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING)
: 0;
return {
statusMessage: message,
isUploading: uploading,
progressValue: progress
};
}, [data?.status, LL]);
const onCancel = useCallback(async () => {
setErrorMessage(undefined);
await setSystemStatus(
SystemStatusCodes.SYSTEM_STATUS_NORMAL as unknown as string
);
await setSystemStatus(String(SystemStatusCodes.SYSTEM_STATUS_NORMAL));
document.location.href = '/';
};
}, [setSystemStatus]);
return (
<Dialog fullWidth={true} sx={dialogStyle} open={true}>
<DialogContent dividers>
<Box m={0} py={0} display="flex" alignItems="center" flexDirection="column">
<Box
sx={{
position: 'fixed',
top: 0,
left: 0,
width: '100vw',
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backdropFilter: 'blur(8px)'
}}
>
<Box
sx={{
width: '30%',
minWidth: '300px',
maxWidth: '500px',
backgroundColor: '#393939',
border: 2,
borderColor: '#565656',
borderRadius: '8px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
p: 3
}}
>
<Box display="flex" alignItems="center" flexDirection="column">
<img
src="/app/icon.png"
alt="EMS-ESP"
style={{ width: '40px', height: '40px', marginBottom: '16px' }}
/>
<Typography
color="secondary"
variant="h6"
fontWeight={400}
textAlign="center"
>
{data?.status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING
? LL.WAIT_FIRMWARE()
: data?.status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART
? LL.APPLICATION_RESTARTING()
: data?.status === SystemStatusCodes.SYSTEM_STATUS_NORMAL
? LL.RESTARTING_PRE()
: data?.status === SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD
? 'Upload Failed'
: LL.RESTARTING_POST()}
{statusMessage}
</Typography>
{errorMessage ? (
<MessageBox my={2} level="error" message={errorMessage}>
<MessageBox level="error" message={errorMessage}>
<Button
size="small"
sx={{ ml: 2 }}
size="small"
startIcon={<CancelIcon />}
variant="contained"
color="error"
onClick={onCancel}
>
{LL.RESET(0)}
{LL.RESTART()}
</Button>
</MessageBox>
) : (
@@ -105,20 +153,16 @@ const SystemMonitor = () => {
<Typography mt={2} variant="h6" fontWeight={400} textAlign="center">
{LL.PLEASE_WAIT()}&hellip;
</Typography>
{data && data.status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING && (
{isUploading && (
<Box width="100%" pl={2} pr={2} py={2}>
<LinearProgressWithLabel
value={Math.round(
data?.status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING
)}
/>
<LinearProgressWithLabel value={progressValue} />
</Box>
)}
</>
)}
</Box>
</DialogContent>
</Dialog>
</Box>
</Box>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,22 @@
import type { FC } from 'react';
import { memo } from 'react';
import { Box } from '@mui/material';
import type { BoxProps } from '@mui/material';
const ButtonRow: FC<BoxProps> = ({ children, ...rest }) => (
const ButtonRow = memo<BoxProps>(({ children, ...rest }) => (
<Box
sx={{
'& button, & a, & .MuiCard-root': {
mt: 2,
mx: 0.6,
'&:last-child': {
mr: 0
},
'&:first-of-type': {
ml: 0
}
'&:last-child': { mr: 0 },
'&:first-of-type': { ml: 0 }
}
}}
{...rest}
>
{children}
</Box>
);
));
export default ButtonRow;

View File

@@ -1,17 +1,7 @@
import { Tooltip, type TooltipProps, styled, tooltipClasses } from '@mui/material';
import { Tooltip, type TooltipProps } from '@mui/material';
export const ButtonTooltip = styled(({ className, ...props }: TooltipProps) => (
<Tooltip {...props} placement="top" arrow classes={{ popper: className }} />
))(({ theme }) => ({
[`& .${tooltipClasses.arrow}`]: {
color: theme.palette.success.main
},
[`& .${tooltipClasses.tooltip}`]: {
backgroundColor: theme.palette.success.main,
color: 'rgba(0, 0, 0, 0.87)',
boxShadow: theme.shadows[1],
fontSize: 10
}
}));
export const ButtonTooltip = ({ children, ...props }: TooltipProps) => (
<Tooltip {...props}>{children}</Tooltip>
);
export default ButtonTooltip;

View File

@@ -1,11 +1,11 @@
import type { FC } from 'react';
import { type FC, memo, useMemo } from 'react';
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
import ErrorIcon from '@mui/icons-material/Error';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import ReportProblemOutlinedIcon from '@mui/icons-material/ReportProblemOutlined';
import { Box, Typography, useTheme } from '@mui/material';
import type { BoxProps, SvgIconProps, Theme } from '@mui/material';
import type { BoxProps, SvgIconProps } from '@mui/material';
type MessageBoxLevel = 'warning' | 'success' | 'info' | 'error';
@@ -14,22 +14,18 @@ export interface MessageBoxProps extends BoxProps {
message?: string;
}
const LEVEL_ICONS: {
[type in MessageBoxLevel]: React.ComponentType<SvgIconProps>;
} = {
const LEVEL_ICONS: Record<MessageBoxLevel, React.ComponentType<SvgIconProps>> = {
success: CheckCircleOutlineOutlinedIcon,
info: InfoOutlinedIcon,
warning: ReportProblemOutlinedIcon,
error: ErrorIcon
};
const LEVEL_BACKGROUNDS: {
[type in MessageBoxLevel]: (theme: Theme) => string;
} = {
success: (theme: Theme) => theme.palette.success.dark,
info: (theme: Theme) => theme.palette.info.main,
warning: (theme: Theme) => theme.palette.warning.dark,
error: (theme: Theme) => theme.palette.error.dark
const LEVEL_PALETTE_PATHS: Record<MessageBoxLevel, string> = {
success: 'success.dark',
info: 'info.main',
warning: 'warning.dark',
error: 'error.dark'
};
const MessageBox: FC<MessageBoxProps> = ({
@@ -40,25 +36,38 @@ const MessageBox: FC<MessageBoxProps> = ({
...rest
}) => {
const theme = useTheme();
const Icon = LEVEL_ICONS[level];
const backgroundColor = LEVEL_BACKGROUNDS[level](theme);
const color = 'white';
const { Icon, backgroundColor } = useMemo(() => {
const Icon = LEVEL_ICONS[level];
const palettePath = LEVEL_PALETTE_PATHS[level];
const [key, shade] = palettePath.split('.') as [
keyof typeof theme.palette,
string
];
const paletteKey = theme.palette[key] as unknown as Record<string, string>;
const backgroundColor = paletteKey[shade];
return { Icon, backgroundColor };
}, [level, theme]);
return (
<Box
p={2}
display="flex"
alignItems="center"
borderRadius={1}
sx={{ backgroundColor, color, ...sx }}
sx={{ backgroundColor, color: 'white', ...sx }}
{...rest}
>
<Icon />
<Typography sx={{ ml: 2 }} variant="body1">
{message ?? ''}
</Typography>
{children}
{(message || children) && (
<Typography sx={{ ml: 2 }} variant="body1">
{message}
{children}
</Typography>
)}
</Box>
);
};
export default MessageBox;
export default memo(MessageBox);

View File

@@ -1,33 +1,28 @@
import { memo } from 'react';
import type { FC } from 'react';
import { Divider, Paper } from '@mui/material';
import { Paper } from '@mui/material';
import type { SxProps, Theme } from '@mui/material/styles';
import type { RequiredChildrenProps } from 'utils';
interface SectionContentProps extends RequiredChildrenProps {
title?: string;
id?: string;
}
const SectionContent: FC<SectionContentProps> = (props) => {
const { children, title, id } = props;
return (
<Paper id={id} sx={{ p: 2, m: 2 }}>
{title && (
<Divider
sx={{
pb: 2,
borderColor: 'primary.main',
fontSize: 20,
color: 'primary.main'
}}
>
{title}
</Divider>
)}
{children}
</Paper>
);
// Extract styles to avoid recreation on every render
const paperStyles: SxProps<Theme> = {
p: 1.5,
m: 1.5,
borderRadius: 3,
border: '1px solid rgb(65, 65, 65)'
};
export default SectionContent;
const SectionContent: FC<SectionContentProps> = ({ children, id }) => (
<Paper id={id} sx={paperStyles}>
{children}
</Paper>
);
// Memoize to prevent unnecessary re-renders
export default memo(SectionContent);

View File

@@ -1,10 +1,15 @@
// use direct exports to reduce bundle size
export { default as SectionContent } from './SectionContent';
export { default as ButtonRow } from './ButtonRow';
export { default as MessageBox } from './MessageBox';
export { default as ButtonTooltip } from './ButtonTooltip';
// Re-export sub-modules
export * from './inputs';
export * from './layout';
export * from './loading';
export * from './routing';
export * from './upload';
export { default as SectionContent } from './SectionContent';
export { default as ButtonRow } from './ButtonRow';
export { default as MessageBox } from './MessageBox';
// Specific routing exports
export { default as BlockNavigation } from './routing/BlockNavigation';
export { default as ButtonTooltip } from './ButtonTooltip';

View File

@@ -1,3 +1,4 @@
import { memo } from 'react';
import type { FC } from 'react';
import { FormControlLabel } from '@mui/material';
@@ -9,4 +10,4 @@ const BlockFormControlLabel: FC<FormControlLabelProps> = (props) => (
</div>
);
export default BlockFormControlLabel;
export default memo(BlockFormControlLabel);

View File

@@ -1,4 +1,6 @@
import { type ChangeEventHandler, useContext } from 'react';
import { memo, useCallback, useContext, useMemo } from 'react';
import type { ChangeEventHandler } from 'react';
import type { CSSProperties } from 'react';
import { MenuItem, TextField } from '@mui/material';
@@ -17,73 +19,66 @@ import { I18nContext } from 'i18n/i18n-react';
import type { Locales } from 'i18n/i18n-types';
import { loadLocaleAsync } from 'i18n/i18n-util.async';
const LanguageSelector = () => {
const { setLocale, locale } = useContext(I18nContext);
const flagStyle: CSSProperties = { width: 16, verticalAlign: 'middle' };
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = async ({
target
}) => {
const loc = target.value as Locales;
localStorage.setItem('lang', loc);
await loadLocaleAsync(loc);
setLocale(loc);
};
interface LanguageOption {
key: Locales;
flag: string;
label: string;
}
const LANGUAGE_OPTIONS: LanguageOption[] = [
{ key: 'cz', flag: CZflag, label: 'CZ' },
{ key: 'de', flag: DEflag, label: 'DE' },
{ key: 'en', flag: GBflag, label: 'EN' },
{ key: 'fr', flag: FRflag, label: 'FR' },
{ key: 'it', flag: ITflag, label: 'IT' },
{ key: 'nl', flag: NLflag, label: 'NL' },
{ key: 'no', flag: NOflag, label: 'NO' },
{ key: 'pl', flag: PLflag, label: 'PL' },
{ key: 'sk', flag: SKflag, label: 'SK' },
{ key: 'sv', flag: SVflag, label: 'SV' },
{ key: 'tr', flag: TRflag, label: 'TR' }
];
const LanguageSelector = () => {
const { setLocale, locale, LL } = useContext(I18nContext);
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = useCallback(
async ({ target }) => {
const loc = target.value as Locales;
localStorage.setItem('lang', 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 (
<TextField
name="locale"
variant="outlined"
aria-label={LL.LANGUAGE()}
value={locale}
onChange={onLocaleSelected}
size="small"
select
>
<MenuItem key="cz" value="cz">
<img src={CZflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;CZ
</MenuItem>
<MenuItem key="de" value="de">
<img src={DEflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;DE
</MenuItem>
<MenuItem key="en" value="en">
<img src={GBflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;EN
</MenuItem>
<MenuItem key="fr" value="fr">
<img src={FRflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;FR
</MenuItem>
<MenuItem key="it" value="it">
<img src={ITflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;IT
</MenuItem>
<MenuItem key="nl" value="nl">
<img src={NLflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;NL
</MenuItem>
<MenuItem key="no" value="no">
<img src={NOflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;NO
</MenuItem>
<MenuItem key="pl" value="pl">
<img src={PLflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;PL
</MenuItem>
<MenuItem key="sk" value="sk">
<img src={SKflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;SK
</MenuItem>
<MenuItem key="sv" value="sv">
<img src={SVflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;SV
</MenuItem>
<MenuItem key="tr" value="tr">
<img src={TRflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;TR
</MenuItem>
{menuItems}
</TextField>
);
};
export default LanguageSelector;
export default memo(LanguageSelector);

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { memo, useCallback, useState } from 'react';
import type { FC } from 'react';
import VisibilityIcon from '@mui/icons-material/Visibility';
@@ -13,6 +13,10 @@ type ValidatedPasswordFieldProps = Omit<ValidatedTextFieldProps, 'type'>;
const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) => {
const [showPassword, setShowPassword] = useState<boolean>(false);
const togglePasswordVisibility = useCallback(() => {
setShowPassword((prev) => !prev);
}, []);
return (
<ValidatedTextField
{...props}
@@ -21,7 +25,11 @@ const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) =
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setShowPassword(!showPassword)} edge="end">
<IconButton
onClick={togglePasswordVisibility}
edge="end"
aria-label="Password visibility"
>
{showPassword ? <VisibilityIcon /> : <VisibilityOffIcon />}
</IconButton>
</InputAdornment>
@@ -32,4 +40,4 @@ const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) =
);
};
export default ValidatedPasswordField;
export default memo(ValidatedPasswordField);

View File

@@ -1,3 +1,4 @@
import { memo } from 'react';
import type { FC } from 'react';
import { FormHelperText, TextField } from '@mui/material';
@@ -14,18 +15,42 @@ export type ValidatedTextFieldProps = ValidatedFieldProps & TextFieldProps;
const ValidatedTextField: FC<ValidatedTextFieldProps> = ({
fieldErrors,
sx,
...rest
}) => {
const errors = fieldErrors && fieldErrors[rest.name];
const renderErrors = () =>
errors &&
errors.map((e) => <FormHelperText key={e.message}>{e.message}</FormHelperText>);
const errors = fieldErrors?.[rest.name];
return (
<>
<TextField error={!!errors} {...rest} />
{renderErrors()}
<TextField
error={!!errors}
{...rest}
aria-label="Error"
sx={{
'& .MuiInputBase-input.Mui-disabled': {
WebkitTextFillColor: 'grey'
},
...(sx || {})
}}
{...(rest.disabled && {
slotProps: {
select: {
IconComponent: () => null
},
inputLabel: {
style: { color: 'grey' }
}
}
})}
color={rest.disabled ? 'secondary' : 'primary'}
/>
{errors?.map((e) => (
<FormHelperText key={e.message} sx={{ color: 'rgb(250, 95, 84)' }}>
{e.message}
</FormHelperText>
))}
</>
);
};
export default ValidatedTextField;
export default memo(ValidatedTextField);

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import type { FC } from 'react';
import { useLocation } from 'react-router';
@@ -13,22 +13,26 @@ import { LayoutContext } from './context';
export const DRAWER_WIDTH = 210;
const Layout: FC<RequiredChildrenProps> = ({ children }) => {
const LayoutComponent: FC<RequiredChildrenProps> = ({ children }) => {
const [mobileOpen, setMobileOpen] = useState(false);
const [title, setTitle] = useState(PROJECT_NAME);
const { pathname } = useLocation();
const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen);
};
// Memoize drawer toggle handler to prevent unnecessary re-renders
const handleDrawerToggle = useCallback(() => {
setMobileOpen((prev) => !prev);
}, []);
useEffect(() => setMobileOpen(false), [pathname]);
// Close drawer when route changes
useEffect(() => {
setMobileOpen(false);
}, [pathname]);
// cache the object to prevent unnecessary re-renders
const obj = useMemo(() => ({ title, setTitle }), [title]);
// Memoize context value to prevent unnecessary re-renders
const contextValue = useMemo(() => ({ title, setTitle }), [title]);
return (
<LayoutContext.Provider value={obj}>
<LayoutContext.Provider value={contextValue}>
<LayoutAppBar title={title} onToggleDrawer={handleDrawerToggle} />
<LayoutDrawer mobileOpen={mobileOpen} onClose={handleDrawerToggle} />
<Box component="main" sx={{ marginLeft: { md: `${DRAWER_WIDTH}px` } }}>
@@ -39,4 +43,6 @@ const Layout: FC<RequiredChildrenProps> = ({ children }) => {
);
};
const Layout = memo(LayoutComponent);
export default Layout;

View File

@@ -1,8 +1,10 @@
import { memo, useCallback, useMemo } from 'react';
import { Link, useLocation, useNavigate } from 'react-router';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import MenuIcon from '@mui/icons-material/Menu';
import { AppBar, IconButton, Toolbar, Typography } from '@mui/material';
import type { SxProps, Theme } from '@mui/material/styles';
import { useI18nContext } from 'i18n/i18n-react';
@@ -13,30 +15,47 @@ interface LayoutAppBarProps {
onToggleDrawer: () => void;
}
const LayoutAppBar = ({ title, onToggleDrawer }: LayoutAppBarProps) => {
// Extract static styles
const appBarStyles: SxProps<Theme> = {
width: { md: `calc(100% - ${DRAWER_WIDTH}px)` },
ml: { md: `${DRAWER_WIDTH}px` },
boxShadow: 'none',
backgroundColor: '#2e586a'
};
const menuButtonStyles: SxProps<Theme> = {
mr: 2,
display: { md: 'none' }
};
const backButtonStyles: SxProps<Theme> = {
mr: 1,
fontSize: 20,
verticalAlign: 'middle'
};
const LayoutAppBarComponent = ({ title, onToggleDrawer }: LayoutAppBarProps) => {
const { LL } = useI18nContext();
const navigate = useNavigate();
const location = useLocation();
const pathnames = useLocation()
.pathname.split('/')
.filter((x) => x);
const pathnames = useMemo(
() => location.pathname.split('/').filter((x) => x),
[location.pathname]
);
const handleBackClick = useCallback(() => {
void navigate('/' + pathnames[0]);
}, [navigate, pathnames]);
return (
<AppBar
position="fixed"
sx={{
width: { md: `calc(100% - ${DRAWER_WIDTH}px)` },
ml: { md: `${DRAWER_WIDTH}px` },
boxShadow: 'none',
backgroundColor: '#2e586a'
}}
>
<AppBar position="fixed" sx={appBarStyles}>
<Toolbar>
<IconButton
color="inherit"
edge="start"
onClick={onToggleDrawer}
sx={{ mr: 2, display: { md: 'none' } }}
sx={menuButtonStyles}
>
<MenuIcon />
</IconButton>
@@ -44,10 +63,10 @@ const LayoutAppBar = ({ title, onToggleDrawer }: LayoutAppBarProps) => {
{pathnames.length > 1 && (
<>
<IconButton
sx={{ mr: 1, fontSize: 20, verticalAlign: 'middle' }}
sx={backButtonStyles}
color="primary"
edge="start"
onClick={() => navigate('/' + pathnames[0])}
onClick={handleBackClick}
>
<ArrowBackIcon />
</IconButton>
@@ -70,4 +89,6 @@ const LayoutAppBar = ({ title, onToggleDrawer }: LayoutAppBarProps) => {
);
};
const LayoutAppBar = memo(LayoutAppBarComponent);
export default LayoutAppBar;

View File

@@ -1,3 +1,5 @@
import { memo, useMemo } from 'react';
import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material';
import { PROJECT_NAME } from 'env';
@@ -21,19 +23,23 @@ interface LayoutDrawerProps {
onClose: () => void;
}
const LayoutDrawerProps = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
const drawer = (
<>
<Toolbar disableGutters>
<Box display="flex" alignItems="center" px={2}>
<LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} />
<Typography variant="h6">{PROJECT_NAME}</Typography>
</Box>
<Divider absolute />
</Toolbar>
<Divider />
<LayoutMenu />
</>
const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
// Memoize drawer content to prevent unnecessary re-renders
const drawer = useMemo(
() => (
<>
<Toolbar disableGutters>
<Box display="flex" alignItems="center" px={2}>
<LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} />
<Typography variant="h6">{PROJECT_NAME}</Typography>
</Box>
<Divider absolute />
</Toolbar>
<Divider />
<LayoutMenu />
</>
),
[]
);
return (
@@ -66,4 +72,6 @@ const LayoutDrawerProps = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
);
};
export default LayoutDrawerProps;
const LayoutDrawer = memo(LayoutDrawerComponent);
export default LayoutDrawer;

View File

@@ -1,4 +1,4 @@
import { useContext, useState } from 'react';
import { memo, useCallback, useContext, useState } from 'react';
import AccountCircleIcon from '@mui/icons-material/AccountCircle';
import AssessmentIcon from '@mui/icons-material/Assessment';
@@ -7,47 +7,24 @@ import ConstructionIcon from '@mui/icons-material/Construction';
import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown';
import LiveHelpIcon from '@mui/icons-material/LiveHelp';
import MoreTimeIcon from '@mui/icons-material/MoreTime';
import PersonIcon from '@mui/icons-material/Person';
import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd';
import SensorsIcon from '@mui/icons-material/Sensors';
import SettingsIcon from '@mui/icons-material/Settings';
import StarIcon from '@mui/icons-material/Star';
import {
Avatar,
Box,
Button,
Divider,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Popover
} from '@mui/material';
import { Box, Divider, List, ListItemButton, ListItemText } from '@mui/material';
import { LanguageSelector } from 'components/inputs';
import LayoutMenuItem from 'components/layout/LayoutMenuItem';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
const LayoutMenu = () => {
const { me, signOut } = useContext(AuthenticatedContext);
const LayoutMenuComponent = () => {
const { me } = useContext(AuthenticatedContext);
const { LL } = useI18nContext();
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const open = Boolean(anchorEl);
const id = anchorEl ? 'app-menu-popover' : undefined;
const [menuOpen, setMenuOpen] = useState(true);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleMenuToggle = useCallback(() => {
setMenuOpen((prev) => !prev);
}, []);
return (
<>
@@ -64,28 +41,13 @@ const LayoutMenu = () => {
>
<ListItemButton
alignItems="flex-start"
onClick={() => setMenuOpen(!menuOpen)}
onClick={handleMenuToggle}
sx={{
pt: 2.5,
pb: menuOpen ? 0 : 2.5,
'&:hover, &:focus': { '& svg': { opacity: 1 } }
}}
>
<ListItemText
primary={LL.MODULES()}
// secondary={
// LL.CUSTOMIZATIONS() +
// ', ' +
// LL.SCHEDULER() +
// ', ' +
// LL.CUSTOM_ENTITIES(0) +
// '...'
// }
// secondaryTypographyProps={{
// noWrap: true,
// fontSize: 12,
// color: menuOpen ? 'rgba(0,0,0,0)' : 'rgba(255,255,255,0.5)'
// }}
sx={{ my: 0 }}
slotProps={{
primary: {
@@ -147,66 +109,13 @@ const LayoutMenu = () => {
to="/settings"
/>
<LayoutMenuItem icon={LiveHelpIcon} label={LL.HELP()} to={`/help`} />
<Divider />
<LayoutMenuItem icon={AccountCircleIcon} label={me.username} to={`/user`} />
</List>
<Divider />
<List>
<ListItem disablePadding>
<ListItemButton component="button" onClick={handleClick}>
<ListItemIcon sx={{ color: '#9e9e9e' }}>
<AccountCircleIcon />
</ListItemIcon>
<ListItemText sx={{ color: '#2196f3' }}>{me.username}</ListItemText>
</ListItemButton>
</ListItem>
</List>
<Popover
id={id}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'center'
}}
>
<Box
padding={2}
justifyContent="center"
flexDirection="column"
sx={{
borderRadius: 3,
border: '3px solid grey'
}}
>
<Button
variant="outlined"
fullWidth
color="primary"
onClick={() => signOut(true)}
>
{LL.SIGN_OUT()}
</Button>
<List>
<ListItem disablePadding>
<Avatar sx={{ bgcolor: '#9e9e9e', color: 'white' }}>
<PersonIcon />
</Avatar>
<ListItemText
sx={{ pl: 2, color: '#2196f3' }}
primary={me.username}
secondary={'(' + (me.admin ? LL.ADMINISTRATOR() : LL.GUEST()) + ')'}
/>
</ListItem>
</List>
<LanguageSelector />
</Box>
</Popover>
</>
);
};
const LayoutMenu = memo(LayoutMenuComponent);
export default LayoutMenu;

View File

@@ -1,7 +1,8 @@
import { memo, useMemo } from 'react';
import { Link, useLocation } from 'react-router';
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
import type { SvgIconProps } from '@mui/material';
import type { SvgIconProps, SxProps, Theme } from '@mui/material';
import { routeMatches } from 'utils';
@@ -12,7 +13,7 @@ interface LayoutMenuItemProps {
disabled?: boolean;
}
const LayoutMenuItem = ({
const LayoutMenuItemComponent = ({
icon: Icon,
label,
to,
@@ -20,18 +21,68 @@ const LayoutMenuItem = ({
}: LayoutMenuItemProps) => {
const { pathname } = useLocation();
const selected = routeMatches(to, pathname);
const selected = useMemo(() => routeMatches(to, pathname), [to, pathname]);
// Memoize dynamic styles based on selected state
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',
borderRadius: '8px',
margin: '2px 8px',
'&:hover': {
backgroundColor: 'rgba(68, 82, 211, 0.39)'
},
'&::before': {
content: '""',
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: selected ? '4px' : '0px',
backgroundColor: '#90caf9',
borderRadius: '0 2px 2px 0',
transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)'
}
}),
[selected]
);
const iconStyles: SxProps<Theme> = useMemo(
() => ({
color: selected ? '#90caf9' : '#9e9e9e',
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
transform: selected ? 'scale(1.1)' : 'scale(1)',
transitionProperty: 'color, transform'
}),
[selected]
);
const textStyles: SxProps<Theme> = useMemo(
() => ({
color: selected ? '#90caf9' : '#f5f5f5',
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
transitionProperty: 'color, font-weight'
}),
[selected]
);
return (
<ListItemButton component={Link} to={to} disabled={disabled} selected={selected}>
<ListItemIcon sx={{ color: selected ? '#90caf9' : '#9e9e9e' }}>
<ListItemButton
component={Link}
to={to}
disabled={disabled || false}
selected={selected}
sx={buttonStyles}
>
<ListItemIcon sx={iconStyles}>
<Icon />
</ListItemIcon>
<ListItemText sx={{ color: selected ? '#90caf9' : '#f5f5f5' }}>
{label}
</ListItemText>
<ListItemText sx={textStyles}>{label}</ListItemText>
</ListItemButton>
);
};
const LayoutMenuItem = memo(LayoutMenuItemComponent);
export default LayoutMenuItem;

View File

@@ -1,3 +1,5 @@
import { memo } from 'react';
import type { CSSProperties } from 'react';
import { Link } from 'react-router';
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
@@ -20,8 +22,14 @@ interface ListMenuItemProps {
disabled?: boolean;
}
function RenderIcon({ icon: Icon, bgcolor, label, text }: ListMenuItemProps) {
return (
const iconStyles: CSSProperties = {
justifyContent: 'right',
color: 'lightblue',
verticalAlign: 'middle'
};
const RenderIcon = memo(
({ icon: Icon, bgcolor, label, text }: ListMenuItemProps) => (
<>
<ListItemAvatar>
<Avatar sx={{ bgcolor, color: 'white' }}>
@@ -30,8 +38,8 @@ function RenderIcon({ icon: Icon, bgcolor, label, text }: ListMenuItemProps) {
</ListItemAvatar>
<ListItemText primary={label} secondary={text} />
</>
);
}
)
);
const LayoutMenuItem = ({
icon,
@@ -46,27 +54,31 @@ const LayoutMenuItem = ({
<ListItem
disablePadding
secondaryAction={
<ListItemIcon
style={{
justifyContent: 'right',
color: 'lightblue',
verticalAlign: 'middle'
}}
>
<ListItemIcon style={iconStyles}>
<NavigateNextIcon />
</ListItemIcon>
}
>
<ListItemButton component={Link} to={to}>
<RenderIcon icon={icon} bgcolor={bgcolor} label={label} text={text} />
<RenderIcon
icon={icon}
{...(bgcolor && { bgcolor })}
label={label}
text={text}
/>
</ListItemButton>
</ListItem>
) : (
<ListItem>
<RenderIcon icon={icon} bgcolor={bgcolor} label={label} text={text} />
<RenderIcon
icon={icon}
{...(bgcolor && { bgcolor })}
label={label}
text={text}
/>
</ListItem>
)}
</>
);
export default LayoutMenuItem;
export default memo(LayoutMenuItem);

View File

@@ -1,3 +1,5 @@
import { memo } from 'react';
import RefreshIcon from '@mui/icons-material/Refresh';
import { Box, Button, CircularProgress } from '@mui/material';
@@ -9,12 +11,12 @@ interface FormLoaderProps {
onRetry?: () => void;
}
const FormLoader = ({ errorMessage, onRetry }: FormLoaderProps) => {
const FormLoaderComponent = ({ errorMessage, onRetry }: FormLoaderProps) => {
const { LL } = useI18nContext();
if (errorMessage) {
return (
<MessageBox my={2} level="error" message={errorMessage}>
<MessageBox level="error" message={errorMessage}>
{onRetry && (
<Button
sx={{ ml: 2 }}
@@ -38,4 +40,6 @@ const FormLoader = ({ errorMessage, onRetry }: FormLoaderProps) => {
);
};
const FormLoader = memo(FormLoaderComponent);
export default FormLoader;

View File

@@ -0,0 +1,23 @@
import { memo } from 'react';
import { Box, CircularProgress } from '@mui/material';
import type { SxProps, Theme } from '@mui/material';
const containerStyles: SxProps<Theme> = {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '200px',
backgroundColor: 'background.default',
borderRadius: 1,
border: '1px solid',
borderColor: 'divider'
};
const LazyLoader = memo(() => (
<Box sx={containerStyles}>
<CircularProgress size={40} />
</Box>
));
export default LazyLoader;

View File

@@ -1,10 +1,17 @@
import { memo } from 'react';
import { Box, CircularProgress } from '@mui/material';
import type { Theme } from '@mui/material';
import type { SxProps, Theme } from '@mui/material';
interface LoadingSpinnerProps {
height?: number | string;
}
const circularProgressStyles: SxProps<Theme> = (theme: Theme) => ({
margin: theme.spacing(4),
color: theme.palette.text.secondary
});
const LoadingSpinner = ({ height = '100%' }: LoadingSpinnerProps) => {
return (
<Box
@@ -15,15 +22,9 @@ const LoadingSpinner = ({ height = '100%' }: LoadingSpinnerProps) => {
padding={2}
height={height}
>
<CircularProgress
sx={(theme: Theme) => ({
margin: theme.spacing(4),
color: theme.palette.text.secondary
})}
size={100}
/>
<CircularProgress sx={circularProgressStyles} size={100} />
</Box>
);
};
export default LoadingSpinner;
export default memo(LoadingSpinner);

View File

@@ -1,2 +1,3 @@
export { default as LoadingSpinner } from './LoadingSpinner';
export { default as FormLoader } from './FormLoader';
export { default as LazyLoader } from './LazyLoader';

View File

@@ -1,3 +1,4 @@
import { memo, useCallback } from 'react';
import type { Blocker } from 'react-router';
import {
@@ -14,23 +15,23 @@ import { useI18nContext } from 'i18n/i18n-react';
const BlockNavigation = ({ blocker }: { blocker: Blocker }) => {
const { LL } = useI18nContext();
const handleReset = useCallback(() => {
blocker.reset?.();
}, [blocker]);
const handleProceed = useCallback(() => {
blocker.proceed?.();
}, [blocker]);
return (
<Dialog sx={dialogStyle} open={blocker.state === 'blocked'}>
<DialogTitle>{LL.BLOCK_NAVIGATE_1()}</DialogTitle>
<DialogContent dividers>{LL.BLOCK_NAVIGATE_2()}</DialogContent>
<DialogActions>
<Button
variant="outlined"
onClick={() => blocker.reset?.()}
color="secondary"
>
<Button variant="outlined" onClick={handleReset} color="secondary">
{LL.STAY()}
</Button>
<Button
variant="contained"
onClick={() => blocker.proceed?.()}
color="primary"
>
<Button variant="contained" onClick={handleProceed} color="primary">
{LL.LEAVE()}
</Button>
</DialogActions>
@@ -38,4 +39,4 @@ const BlockNavigation = ({ blocker }: { blocker: Blocker }) => {
);
};
export default BlockNavigation;
export default memo(BlockNavigation);

View File

@@ -1,4 +1,4 @@
import { useContext } from 'react';
import { memo, useContext } from 'react';
import type { FC } from 'react';
import { Navigate } from 'react-router';
@@ -14,4 +14,4 @@ const RequireAdmin: FC<RequiredChildrenProps> = ({ children }) => {
);
};
export default RequireAdmin;
export default memo(RequireAdmin);

View File

@@ -1,4 +1,4 @@
import { useContext, useEffect } from 'react';
import { memo, useContext, useEffect } from 'react';
import type { FC } from 'react';
import { Navigate, useLocation } from 'react-router';
@@ -18,7 +18,7 @@ const RequireAuthenticated: FC<RequiredChildrenProps> = ({ children }) => {
if (!authenticationContext.me) {
storeLoginRedirect(location);
}
});
}, [authenticationContext.me, location]);
return authenticationContext.me ? (
<AuthenticatedContext.Provider
@@ -31,4 +31,4 @@ const RequireAuthenticated: FC<RequiredChildrenProps> = ({ children }) => {
);
};
export default RequireAuthenticated;
export default memo(RequireAuthenticated);

View File

@@ -1,4 +1,4 @@
import { useContext } from 'react';
import { memo, useContext } from 'react';
import type { FC } from 'react';
import { Navigate } from 'react-router';
@@ -16,4 +16,4 @@ const RequireUnauthenticated: FC<RequiredChildrenProps> = ({ children }) => {
);
};
export default RequireUnauthenticated;
export default memo(RequireUnauthenticated);

View File

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

View File

@@ -1,6 +1,5 @@
import type { Path } from 'react-router';
import type * as H from 'history';
import { jwtDecode } from 'jwt-decode';
import type { Me, SignInRequest, SignInResponse } from 'types';
@@ -14,11 +13,17 @@ export const verifyAuthorization = () =>
export const signIn = (request: SignInRequest) =>
alovaInstance.Post<SignInResponse>('/rest/signIn', request);
// Cache storage reference to avoid repeated checks
let cachedStorage: Storage | undefined;
export function getStorage() {
return localStorage || sessionStorage;
if (!cachedStorage) {
cachedStorage = localStorage || sessionStorage;
}
return cachedStorage;
}
export function storeLoginRedirect(location?: H.Location) {
export function storeLoginRedirect(location?: { pathname: string; search: string }) {
if (location) {
getStorage().setItem(SIGN_IN_PATHNAME, location.pathname);
getStorage().setItem(SIGN_IN_SEARCH, location.search);
@@ -36,7 +41,7 @@ export function fetchLoginRedirect(): Partial<Path> {
clearLoginRedirect();
return {
pathname: signInPathname || `/dashboard`,
search: (signInPathname && signInSearch) || undefined
...(signInPathname && signInSearch && { search: signInSearch })
};
}

View File

@@ -1,16 +1,59 @@
// Code inspired by Prince Azubuike from https://medium.com/@dprincecoder/creating-a-drag-and-drop-file-upload-component-in-react-a-step-by-step-guide-4d93b6cc21e0
import { type ChangeEvent, useRef, useState } from 'react';
import {
type ChangeEvent,
type DragEvent,
type MouseEvent,
useRef,
useState
} from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import UploadIcon from '@mui/icons-material/Upload';
import { Box, Button } from '@mui/material';
import { Box, Button, Typography, styled } from '@mui/material';
import { useI18nContext } from 'i18n/i18n-react';
import './dragNdrop.css';
const DocumentUploader = styled(Box)<{ active?: boolean }>(({ theme, active }) => ({
border: `2px dashed ${active ? '#6dc24b' : '#4282fe'}`,
backgroundColor: '#2e3339',
padding: theme.spacing(1.25),
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
borderRadius: theme.spacing(1),
cursor: 'pointer',
minHeight: '120px',
transition: 'border-color 0.2s ease-in-out'
}));
const DragNdrop = ({ text, onFileSelected }) => {
const UploadInfo = styled(Box)({
display: 'flex',
alignItems: 'center'
});
const FileInfo = styled(Box)({
display: 'flex',
flexDirection: 'column',
width: '100%',
justifyContent: 'space-between',
alignItems: 'center'
});
const FileName = styled(Typography)(({ theme }) => ({
fontSize: '14px',
color: '#6dc24b',
margin: theme.spacing(1, 0)
}));
interface DragNdropProps {
text: string;
onFileSelected: (file: File) => void;
}
const DragNdrop = ({ text, onFileSelected }: DragNdropProps) => {
const [file, setFile] = useState<File>();
const [dragged, setDragged] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null);
@@ -28,14 +71,17 @@ const DragNdrop = ({ text, onFileSelected }) => {
};
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
if (!e.target.files) {
if (!e.target.files || e.target.files.length === 0) {
return;
}
checkFileExtension(e.target.files[0]);
const selectedFile = e.target.files[0];
if (selectedFile) {
checkFileExtension(selectedFile);
}
e.target.value = ''; // this is to allow the same file to be selected again
};
const handleDrop = (event) => {
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
const droppedFiles = event.dataTransfer.files;
if (droppedFiles.length > 0) {
@@ -43,38 +89,40 @@ const DragNdrop = ({ text, onFileSelected }) => {
}
};
const handleRemoveFile = (event) => {
const handleRemoveFile = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
setFile(undefined);
setDragged(false);
};
const handleUploadClick = (event) => {
const handleUploadClick = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onFileSelected(file);
if (file) {
onFileSelected(file);
}
};
const handleBrowseClick = () => {
inputRef.current?.click();
};
const handleDragOver = (event) => {
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault(); // prevent file from being opened
setDragged(true);
};
return (
<div
className={`document-uploader ${file || dragged ? 'active' : ''}`}
<DocumentUploader
active={!!(file || dragged)}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={() => setDragged(false)}
onClick={handleBrowseClick}
>
<div className="upload-info">
<UploadInfo>
<CloudUploadIcon sx={{ marginRight: 4 }} color="primary" fontSize="large" />
<p>{text}</p>
</div>
<Typography>{text}</Typography>
</UploadInfo>
<input
type="file"
@@ -88,9 +136,9 @@ const DragNdrop = ({ text, onFileSelected }) => {
{file && (
<>
<div className="file-info">
<p>{file.name}</p>
</div>
<FileInfo>
<FileName>{file.name}</FileName>
</FileInfo>
<Box>
<Button
startIcon={<CancelIcon />}
@@ -112,7 +160,7 @@ const DragNdrop = ({ text, onFileSelected }) => {
</Box>
</>
)}
</div>
</DocumentUploader>
);
};

View File

@@ -12,7 +12,12 @@ import { useI18nContext } from 'i18n/i18n-react';
import DragNdrop from './DragNdrop';
import { LinearProgressWithLabel } from './LinearProgressWithLabel';
const SingleUpload = ({ text, doRestart }) => {
interface SingleUploadProps {
text: string;
doRestart: () => void;
}
const SingleUpload = ({ text, doRestart }: SingleUploadProps) => {
const [md5, setMd5] = useState<string>();
const [file, setFile] = useState<File>();
const { LL } = useI18nContext();
@@ -25,8 +30,8 @@ const SingleUpload = ({ text, doRestart }) => {
} = useRequest(SystemApi.uploadFile, {
immediate: false
}).onSuccess(({ data }) => {
if (data) {
setMd5(data.md5 as string);
if (data && typeof data === 'object' && 'md5' in data) {
setMd5((data as { md5: string }).md5);
toast.success(LL.UPLOAD() + ' MD5 ' + LL.SUCCESSFUL());
setFile(undefined);
} else {
@@ -34,16 +39,19 @@ const SingleUpload = ({ text, doRestart }) => {
}
});
useEffect(async () => {
if (file) {
await sendUpload(file).catch((error: Error) => {
if (error.message === 'The user aborted a request') {
toast.warning(LL.UPLOAD() + ' ' + LL.ABORTED());
} else {
toast.warning('Invalid file extension or incompatible bin file');
}
});
}
useEffect(() => {
const uploadFile = async () => {
if (file) {
await sendUpload(file).catch((error: Error) => {
if (error.message.includes('The user aborted a request')) {
toast.warning(LL.UPLOAD() + ' ' + LL.ABORTED());
} else {
toast.warning('Invalid file extension or incompatible bin file');
}
});
}
};
void uploadFile();
}, [file]);
return (

View File

@@ -1,33 +0,0 @@
.document-uploader {
border: 2px dashed #4282fe;
background-color: #2e3339;
padding: 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
border-radius: 8px;
cursor: pointer;
&.active {
border-color: #6dc24b;
}
.upload-info {
display: flex;
align-items: center;
}
.file-info {
display: flex;
flex-direction: column;
width: 100%;
justify-content: space-between;
align-items: center;
p {
font-size: 14px;
color: #6dc24b;
}
}
}

View File

@@ -69,7 +69,12 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
// cache object to prevent re-renders
const obj = useMemo(
() => ({ signIn, signOut, me, refresh }),
() => ({
signIn,
signOut,
refresh,
...(me && { me })
}),
[signIn, signOut, me, refresh]
);

View File

@@ -40,7 +40,7 @@ const cz: Translation = {
RUN_COMMAND: 'Zavolat příkaz',
CHANGE_VALUE: 'Změnit hodnotu',
CANCEL: 'Zrušit',
RESET: 'Resetovat',
REMOVE_ALL: 'Odebrat vše',
APPLY_CHANGES: 'Použít změny ({0})',
UPDATE: 'Aktualizovat',
EXECUTE: 'Provést',
@@ -60,7 +60,6 @@ const cz: Translation = {
DUTY_CYCLE: 'Pracovní cyklus',
UNIT: 'Jednotka',
STARTVALUE: 'Počáteční hodnota',
WARN_GPIO: 'Upozornění: buďte opatrní při přiřazování GPIO!',
EDIT: 'Upravit',
SENSOR: 'Senzor',
TEMP_SENSOR: 'Teplotní senzor',
@@ -153,7 +152,7 @@ const cz: Translation = {
SET_ALL: 'nastavit vše',
OPTIONS: 'Možnosti',
NAME: 'Název',
CUSTOMIZATIONS_RESET: 'Opravdu chcete odstranit všechna přizpůsobení včetně vlastních nastavení teplotních a analogových senzorů?',
CUSTOMIZATIONS_RESET: 'Opravdu chcete odstranit všechna přizpůsobení?',
SUPPORT_INFORMATION: 'Podpora',
HELP_INFORMATION_1: 'Navštivte online wiki pro pokyny, jak konfigurovat EMS-ESP',
HELP_INFORMATION_2: 'Pro živý chat s komunitou se připojte k našemu serveru Discord',
@@ -162,6 +161,7 @@ const cz: Translation = {
UPLOAD: 'Nahrát',
DOWNLOAD: '{{S|s|s}}táhnout',
INSTALL: 'Instalovat',
REINSTALL: 'Znovu instalovat',
ABORTED: 'přerušeno',
FAILED: 'neúspěšné',
SUCCESSFUL: 'úspěšné',
@@ -186,8 +186,9 @@ const cz: Translation = {
BUFFER_SIZE: 'Maximální velikost vyrovnávací paměti',
COMPACT: 'Kompaktní',
DOWNLOAD_SETTINGS_TEXT: 'Vytvořte zálohu svého nastavení a konfigurace',
DOWNLOAD_SETTINGS_TEXT2: 'Exportovat všechna data',
UPLOAD_TEXT: 'Nahrajte nový soubor firmwaru (.bin) nebo záložní soubor (.json)',
UPLOAD_DROP_TEXT: 'Drop a firmware .bin file or click here', // TODO translate
UPLOAD_DROP_TEXT: 'Přetáhněte soubor sem nebo klikněte pro výběr',
ERROR: 'Neočekávaná chyba, zkuste to prosím znovu',
TIME_SET: 'Čas nastaven',
MANAGE_USERS: 'Spravovat uživatele',
@@ -331,28 +332,31 @@ const cz: Translation = {
ALLVALUES: 'Všechny hodnoty',
SPECIAL_FUNCTIONS: 'Speciální funkce',
WAIT_FIRMWARE: 'Firmware se nahrává a instaluje',
INSTALL_VERSION: 'Tímto se instalovat verze {0}. Jste si jistí?',
UPGRADE_AVAILABLE: 'Je k dispozici aktualizace firmwaru!',
LATEST_VERSION: 'Používáte nejnovější verzi firmwaru',
INSTALL_VERSION: 'Tímto se {0} verze {1}. Jste si jistí?',
UPDATE_AVAILABLE: 'aktualizace dostupná',
LATEST_VERSION: 'Používáte nejnovější verzi {0}firmwaru',
PLEASE_WAIT: 'Prosím čekejte',
RESTARTING_PRE: 'Inicializace',
RESTARTING_PRE: 'Bootování',
RESTARTING_POST: 'Příprava',
AUTO_SCROLL: 'Automatické rolování',
DASHBOARD: 'Dashboard',
DEVELOPER_MODE: 'Režim vývojáře',
BYTES: 'Bytes', // TODO translate
BITMASK: 'Bit Mask',// TODO translate
BYTES: 'Bajty',
BITMASK: 'Bit Mask',
DUPLICATE: 'Duplikát',
UPGRADE: 'Upgrade',
DASHBOARD_1: 'Všechny aktivní entity EMS jsou označené jako oblíbené. Všechny vlastní entity, harmonogramy a externí sensory jsou zobrazeny níže.',
NO_DATA_1: 'Nebyly nalezeny žádné oblíbené entity. Použijte',
NO_DATA_2: 'modul sloužící k jejich výběru.',
NO_DATA_3: 'Pro zobrazení všech dostupných entit navštivte',
NO_DATA_1: 'Nebyly nalezeny žádné oblíbené entity. Použijte modul',
NO_DATA_2: 'pro jejich výběr.',
NO_DATA_3: 'Pro zobrazení všech dostupných entit navštivte stránku',
THIS_VERSION: 'Tato verze',
PLATFORM: 'Platforma',
RELEASE_TYPE: 'Typ sestavení',
REINSTALL: 'Přeinstalovat',
INTERNET_CONNECTION_REQUIRED: 'Pro automatickou kontrolu a instalaci aktualizací je třeba internetové připojení',
SWITCH_RELEASE_TYPE: 'Přepnout na {0} verzi',
FIRMWARE_VERSION_INFO: 'Informace o verzi firmwaru',
NO_DATA: 'Žádná data',
USER_PROFILE: 'Uživatelský profil',
STORED_VERSIONS: 'Uložené verze'
};
export default cz;

View File

@@ -40,9 +40,9 @@ const de: Translation = {
RUN_COMMAND: 'Befehl ausführen',
CHANGE_VALUE: 'Wert ändern',
CANCEL: 'Abbrechen',
RESET: 'Zurücksetzen',
REMOVE_ALL: 'Alles entfernen',
APPLY_CHANGES: 'Änderungen anwenden ({0})',
UPDATE: 'Update',
UPDATE: 'Aktualisieren',
EXECUTE: 'Ausführen',
REMOVE: 'Entfernen',
PROBLEM_UPDATING: 'Problem beim Aktualisieren',
@@ -60,7 +60,6 @@ const de: Translation = {
DUTY_CYCLE: 'Arbeitszyklus',
UNIT: 'Maßeinheit',
STARTVALUE: 'Startwert',
WARN_GPIO: 'Warnung: Vorsicht bei der korrekten Wahl des GPIO!',
EDIT: 'Editiere',
SENSOR: 'Sensor',
TEMP_SENSOR: 'Temperatursensor',
@@ -153,15 +152,16 @@ const de: Translation = {
SET_ALL: 'Setzen Sie alle',
OPTIONS: 'Optionen',
NAME: 'Name',
CUSTOMIZATIONS_RESET: 'Möchten Sie wirklich alle Anpassungen entfernen, einschließlich der benutzerdefinierten Einstellungen der Temperatur- und Analogsensoren?',
CUSTOMIZATIONS_RESET: 'Möchten Sie wirklich alle Anpassungen entfernen?',
SUPPORT_INFORMATION: 'Unterstützende Informationen',
HELP_INFORMATION_1: 'EMS-ESP Konfigurationsanweisungen und mehr finden Sie im Online-Wiki',
HELP_INFORMATION_2: 'Für einen Live-Community-Chat besuchen Sie unseren Discord-Server',
HELP_INFORMATION_3: 'Um neue Funktionen anzufragen oder Fehler zu melden, eröffnen Sie ein Issue auf GitHub',
HELP_INFORMATION_4: 'Bitte laden Sie die Systemdetails und hängen Sie sie an das Support-Issue an',
UPLOAD: 'Hochladen',
DOWNLOAD: '{{H|h|h}}erunterladen',
DOWNLOAD: '{{Herunterladen|heruntergeladen|}}',
INSTALL: 'Installieren',
REINSTALL: 'Neu installieren',
ABORTED: 'abgebrochen',
FAILED: 'gescheitert',
SUCCESSFUL: 'erfolgreich',
@@ -186,6 +186,7 @@ const de: Translation = {
BUFFER_SIZE: 'Max. Puffergröße',
COMPACT: 'Kompakte Darstellung',
DOWNLOAD_SETTINGS_TEXT: 'Erstellen Sie eine Sicherung Ihrer Konfigurationen und Einstellungen',
DOWNLOAD_SETTINGS_TEXT2: 'Exportiere alle Daten',
UPLOAD_TEXT: 'Laden Sie eine neue Firmware-Datei (.bin) oder eine Sicherungsdatei (.json) hoch',
UPLOAD_DROP_TEXT: 'Legen Sie eine Firmware-Datei (.bin) ab oder klicken Sie hier',
ERROR: 'Unerwarteter Fehler, bitte versuchen Sie es erneut.',
@@ -331,11 +332,11 @@ const de: Translation = {
ALLVALUES: 'Alle Werte',
SPECIAL_FUNCTIONS: 'Sonderfunktionen',
WAIT_FIRMWARE: 'Die Firmware wird hochgeladen und installiert.',
INSTALL_VERSION: 'Dadurch wird die Version {0} heruntergeladen. Sind Sie sicher?',
UPGRADE_AVAILABLE: 'Es ist ein Firmware-Upgrade verfügbar.',
LATEST_VERSION: 'Sie verwenden die neueste Firmware-Version',
INSTALL_VERSION: 'Dadurch wird die Version {1} {0}. Sind Sie sicher?',
UPDATE_AVAILABLE: 'Firmware-Update verfügbar',
LATEST_VERSION: 'Sie verwenden die neueste {0} Firmware-Version',
PLEASE_WAIT: 'Bitte warten',
RESTARTING_PRE: 'Initialisierung',
RESTARTING_PRE: 'Booten',
RESTARTING_POST: 'Vorbereitung',
AUTO_SCROLL: 'Automatisches Scrollen',
DASHBOARD: 'Dashboard',
@@ -343,7 +344,6 @@ const de: Translation = {
BYTES: 'Bytes',
BITMASK: 'Bit Maske',
DUPLICATE: 'Kopieren',
UPGRADE: 'Aktualisieren',
DASHBOARD_1: 'Alle EMS-Entitäten, die aktiv und als Favorit markiert sind, sowie alle benutzerdefinierten Entitäten, Zeitpläne und externen Sensordaten werden unten angezeigt.',
NO_DATA_1: 'Keine favorisierten EMS-Entitäten gefunden! Verwenden Sie das Modul',
NO_DATA_2: ', um sie zu markieren.',
@@ -351,8 +351,12 @@ const de: Translation = {
THIS_VERSION: 'Diese Version',
PLATFORM: 'Plattform',
RELEASE_TYPE: 'Release Typ',
REINSTALL: 'Neu installieren',
INTERNET_CONNECTION_REQUIRED: 'Internetverbindung erforderlich für automatische Version-Überprüfung und -Aktualisierung',
INTERNET_CONNECTION_REQUIRED: 'Für die automatische Versionsprüfung und Aktualisierung ist eine Internetverbindung erforderlich',
SWITCH_RELEASE_TYPE: 'Zum {0}-Release wechseln',
FIRMWARE_VERSION_INFO: 'Firmware-Versionsinformation',
NO_DATA: 'Keine Daten',
USER_PROFILE: 'Benutzerprofil',
STORED_VERSIONS: 'Gespeicherte Versionen'
};
export default de;

View File

@@ -40,7 +40,7 @@ const en: Translation = {
RUN_COMMAND: 'Call Command',
CHANGE_VALUE: 'Change Value',
CANCEL: 'Cancel',
RESET: 'Reset',
REMOVE_ALL: 'Remove All',
APPLY_CHANGES: 'Apply Changes ({0})',
UPDATE: 'Update',
EXECUTE: 'Execute',
@@ -60,7 +60,6 @@ const en: Translation = {
DUTY_CYCLE: 'Duty Cycle',
UNIT: 'UoM',
STARTVALUE: 'Start Value',
WARN_GPIO: 'Warning: be careful when assigning a GPIO!',
EDIT: 'Edit',
SENSOR: 'Sensor',
TEMP_SENSOR: 'Temperature Sensor',
@@ -153,7 +152,7 @@ const en: Translation = {
SET_ALL: 'set all',
OPTIONS: 'Options',
NAME: 'Name',
CUSTOMIZATIONS_RESET: 'Are you sure you want remove all customizations including the custom settings of the Temperature and Analog sensors?',
CUSTOMIZATIONS_RESET: 'Are you sure you want remove all the customizations?',
SUPPORT_INFORMATION: 'Support Information',
HELP_INFORMATION_1: 'Visit the online wiki to get instructions on how to configure EMS-ESP',
HELP_INFORMATION_2: 'For live community chat join our Discord server',
@@ -162,6 +161,7 @@ const en: Translation = {
UPLOAD: 'Upload',
DOWNLOAD: '{{D|d|d}}ownload',
INSTALL: 'Install',
REINSTALL: 'Reinstall',
ABORTED: 'aborted',
FAILED: 'failed',
SUCCESSFUL: 'successful',
@@ -179,13 +179,14 @@ const en: Translation = {
DEVELOPMENT: 'Development',
UPTIME: 'System Uptime',
FREE_MEMORY: 'Free Memory',
PSRAM: 'PSRAM (Size / Free)',
FLASH: 'Flash Chip (Size , Speed)',
APPSIZE: 'Application (Partition: Used / Free)',
FILESYSTEM: 'File System (Used / Free)',
PSRAM: 'PSRAM (size / free)',
FLASH: 'Flash Chip (size , speed)',
APPSIZE: 'Application (partition: used / free)',
FILESYSTEM: 'File System (used / free)',
BUFFER_SIZE: 'Max Buffer Size',
COMPACT: 'Compact',
DOWNLOAD_SETTINGS_TEXT: 'Create a backup of your configuration and settings',
DOWNLOAD_SETTINGS_TEXT2: 'Export all data',
UPLOAD_TEXT: 'Upload a new firmware file (.bin) or a backup file (.json)',
UPLOAD_DROP_TEXT: 'Drop a firmware .bin file or click here',
ERROR: 'Unexpected Error, please try again',
@@ -331,11 +332,11 @@ const en: Translation = {
ALLVALUES: 'All Values',
SPECIAL_FUNCTIONS: 'Special Functions',
WAIT_FIRMWARE: 'Firmware is uploading and installing',
INSTALL_VERSION: 'This will install version {0}. Are you sure?',
UPGRADE_AVAILABLE: 'There is a firmware upgrade available!',
LATEST_VERSION: 'You are using the latest firmware version',
INSTALL_VERSION: 'This will {0} version {1}. Are you sure?',
UPDATE_AVAILABLE: 'update available',
LATEST_VERSION: 'You are using the latest {0} firmware version',
PLEASE_WAIT: 'Please wait',
RESTARTING_PRE: 'Initializing',
RESTARTING_PRE: 'Booting',
RESTARTING_POST: 'Preparing',
AUTO_SCROLL: 'Auto Scroll',
DASHBOARD: 'Dashboard',
@@ -344,15 +345,18 @@ const en: Translation = {
BYTES: 'Bytes',
BITMASK: 'Bit Mask',
DUPLICATE: 'Duplicate',
UPGRADE: 'Upgrade',
NO_DATA_1: 'No favorite EMS entities found yet. Use the',
NO_DATA_2: 'module to mark them.',
NO_DATA_3: 'To see all available entities go to',
THIS_VERSION: 'This Version',
PLATFORM: 'Platform',
RELEASE_TYPE: 'Release Type',
REINSTALL: 'Re-install',
INTERNET_CONNECTION_REQUIRED: 'Internet connection required for automatic version checking and upgrading',
SWITCH_RELEASE_TYPE: 'Switch to {0} release',
FIRMWARE_VERSION_INFO: 'Firmware Version Information',
NO_DATA: 'No data',
USER_PROFILE: 'User Profile',
STORED_VERSIONS: 'Stored Versions'
};
export default en;

View File

@@ -40,10 +40,10 @@ const fr: Translation = {
RUN_COMMAND: 'Lancer une commande',
CHANGE_VALUE: 'Changer la valeur',
CANCEL: 'Annuler',
RESET: 'Réinitialiser',
APPLY_CHANGES: 'Apply Changes ({0})', // TODO translate
UPDATE: 'Update', // TODO translate
EXECUTE: 'Execute', // TODO translate
REMOVE_ALL: 'Supprimer tout',
APPLY_CHANGES: 'Appliquer les changements ({0})',
UPDATE: 'Update',
EXECUTE: 'Execute',
REMOVE: 'Enlever',
PROBLEM_UPDATING: 'Problème lors de la mise à jour',
PROBLEM_LOADING: 'Problème lors du chargement',
@@ -60,19 +60,18 @@ const fr: Translation = {
DUTY_CYCLE: 'Cycle de fonctionnement',
UNIT: 'Unité',
STARTVALUE: 'Valeur de départ',
WARN_GPIO: 'Attention: soyez vigilant en choisissant un GPIO!',
EDIT: 'Éditer',
SENSOR: 'Capteur',
TEMP_SENSOR: 'Capteur de température',
TEMP_SENSORS: 'Capteurs de température',
WRITE_CMD_SENT: 'Envoyer la commande sent',
EMS_BUS_WARNING: 'Bus EMS déconnecté. Si ce message persiste après quelques secondes, vérifiez les paramètres et la configuration de la carte.',
EMS_BUS_WARNING: 'Bus EMS déconnecté. Si ce message persiste après quelques secondes, vérifiez les paramètres et la configuration de la carte.',
EMS_BUS_SCANNING: 'Scan des appareils EMS...',
CONNECTED: 'Connecté',
TX_ISSUES: 'Problèmes de transmission (Tx) - Essayez un autre mode Tx',
DISCONNECTED: 'Déconnecté',
EMS_SCAN: 'Etes-vous sûr de vouloir lancer un scan complet du bus EMS ?',
DATA_TRAFFIC: 'Data Traffic', // TODO translate
DATA_TRAFFIC: 'Traffic des données',
EMS_DEVICE: 'Appareils EMS',
SUCCESS: 'SUCCÈS',
FAIL: 'ÉCHEC',
@@ -114,9 +113,9 @@ const fr: Translation = {
BYPASS_TOKEN: "Contourner l'autorisation du jeton d'accès sur les appels API",
READONLY: 'Activer le mode lecture uniquement (bloque toutes les commandes EMS sortantes en écriture Tx)',
UNDERCLOCK_CPU: 'Underclock du CPU',
HEATINGOFF: 'Start boiler with forced heating off', // TODO translate
HEATINGOFF: 'Démarrer le chauffage avec le chauffage forcé éteint',
REMOTE_TIMEOUT: 'Remote timeout',
REMOTE_TIMEOUT_EN: 'Disable remote on missing room temperature', // TODO translate
REMOTE_TIMEOUT_EN: 'Disable remote on missing room temperature',
MIN_DURATION: 'Wait time',
ENABLE_SHOWER_TIMER: 'Activer la minuterie de la douche',
ENABLE_SHOWER_ALERT: 'Activer les alertes de durée de douche',
@@ -148,12 +147,12 @@ const fr: Translation = {
CUSTOMIZATIONS_HELP_3: "désactiver l'action d'écriture",
CUSTOMIZATIONS_HELP_4: "exclure de MQTT et de l'API",
CUSTOMIZATIONS_HELP_5: 'cacher des appareils',
CUSTOMIZATIONS_HELP_6: 'remove from memory', // TODO translate
CUSTOMIZATIONS_HELP_6: 'supprimer de la mémoire',
SELECT_DEVICE: 'Sélectionnez un appareil',
SET_ALL: 'tout régler',
OPTIONS: 'Options',
NAME: 'Nom',
CUSTOMIZATIONS_RESET: 'Êtes-vous sûr de vouloir supprimer toutes les personnalisations, y compris les paramètres personnalisés des capteurs de température et analogiques ?',
CUSTOMIZATIONS_RESET: 'Êtes-vous sûr de vouloir supprimer toutes les personnalisations?',
SUPPORT_INFORMATION: 'Information de support',
HELP_INFORMATION_1: 'Visitez le wiki en ligne pour obtenir des instructions sur la façon de configurer EMS-ESP',
HELP_INFORMATION_2: 'Pour une discussion en direct avec la communauté, rejoignez notre serveur Discord',
@@ -162,20 +161,21 @@ const fr: Translation = {
UPLOAD: 'Upload',
DOWNLOAD: '{{D|d|d}}ownload',
INSTALL: 'Installer',
REINSTALL: 'Réinstaller',
ABORTED: 'annulé',
FAILED: 'échoué',
SUCCESSFUL: 'réussi',
SYSTEM: 'Système',
LOG_OF: '{0} Log',
STATUS_OF: 'Statut {0}',
DOWNLOAD_UPLOAD: 'Download/Upload', // TODO translate
DOWNLOAD_UPLOAD: 'Télécharger/Mettre à jour',
CLOSE: 'Fermer',
USE: 'Utiliser',
FACTORY_RESET: 'Réinitialisation',
SYSTEM_FACTORY_TEXT: "L'appareil a été réinitialisé et va maintenant redémarrer",
SYSTEM_FACTORY_TEXT_DIALOG: "Êtes-vous sûr de vouloir réinitialiser l'appareil à ses paramètres d'usine ?",
AVAILABLE_VERSION: 'Latest Available Versions', // TODO translate
STABLE: 'Stable', // TODO translate
AVAILABLE_VERSION: 'Versions disponibles',
STABLE: 'Stable',
DEVELOPMENT: 'Développement',
UPTIME: 'Durée de fonctionnement du système',
FREE_MEMORY: 'Libre Memory',
@@ -185,9 +185,10 @@ const fr: Translation = {
FILESYSTEM: 'File System (Utilisée / Libre)',
BUFFER_SIZE: 'Max taille du buffer',
COMPACT: 'Compact',
DOWNLOAD_SETTINGS_TEXT: 'Create a backup of your configuration and settings', // TODO translate
UPLOAD_TEXT: 'Upload a new firmware file (.bin) or a backup file (.json)', // TODO translate
UPLOAD_DROP_TEXT: 'Drop a firmware .bin file or click here', // TODO translate
DOWNLOAD_SETTINGS_TEXT: 'Créer une sauvegarde de vos paramètres et configurations',
DOWNLOAD_SETTINGS_TEXT2: 'Exporter toutes les données',
UPLOAD_TEXT: 'Télécharger un nouveau fichier firmware (.bin) ou une sauvegarde (.json)',
UPLOAD_DROP_TEXT: 'Drop a firmware .bin file or click here',
ERROR: 'Erreur inattendue, veuillez réessayer',
TIME_SET: 'Time set',
MANAGE_USERS: 'Gérer les utilisateurs',
@@ -217,7 +218,7 @@ const fr: Translation = {
MQTT_PUBLISH_TEXT_2: 'Publier vers des topics de commande (ioBroker)',
MQTT_PUBLISH_TEXT_3: 'Activer la découverte MQTT',
MQTT_PUBLISH_TEXT_4: 'Préfixe pour les topics découverte',
MQTT_PUBLISH_TEXT_5: 'Discovery type', // TODO translate
MQTT_PUBLISH_TEXT_5: 'Type de découverte',
MQTT_PUBLISH_INTERVALS: 'Intervalles de publication',
MQTT_INT_BOILER: 'Chaudières et pompes à chaleur',
MQTT_INT_THERMOSTATS: 'Thermostats',
@@ -226,10 +227,10 @@ const fr: Translation = {
MQTT_INT_WATER: 'Modules eau',
MQTT_QUEUE: 'Queue MQTT',
DEFAULT: 'Défaut',
MQTT_ENTITY_FORMAT: 'Entity ID format', // TODO translate
MQTT_ENTITY_FORMAT_0: 'Single instance, long name (v3.4)', // TODO translate
MQTT_ENTITY_FORMAT_1: 'Single instance, short name', // TODO translate
MQTT_ENTITY_FORMAT_2: 'Multiple instances, short name', // TODO translate
MQTT_ENTITY_FORMAT: 'Format de l\'ID de l\'entité',
MQTT_ENTITY_FORMAT_0: 'Instance unique, nom long (v3.4)',
MQTT_ENTITY_FORMAT_1: 'Instance unique, nom court',
MQTT_ENTITY_FORMAT_2: 'Instances multiples, nom court',
MQTT_CLEAN_SESSION: 'Flag Clean Session',
MQTT_RETAIN_FLAG: 'Toujours activer le Retain Flag',
INACTIVE: 'Inactif',
@@ -260,7 +261,7 @@ const fr: Translation = {
NETWORK_SCANNER: 'Scan réseau',
NETWORK_NO_WIFI: 'Pas de réseau WiFi trouvé',
NETWORK_BLANK_SSID: 'laisser vide pour désactiver le WiFi',
NETWORK_BLANK_BSSID: 'leave blank to use only SSID', // TODO translate
NETWORK_BLANK_BSSID: 'laisser vide pour utiliser uniquement le SSID',
TX_POWER: 'Puissance Tx',
HOSTNAME: "Nom d'hôte",
NETWORK_DISABLE_SLEEP: 'Désactiver le mode veille du WiFi',
@@ -280,79 +281,82 @@ const fr: Translation = {
ENTITY: 'entité',
MIN: 'min',
MAX: 'max',
BLOCK_NAVIGATE_1: 'You have unsaved changes', // TODO translate
BLOCK_NAVIGATE_2: 'If you navigate to a different page, your unsaved changes will be lost. Are you sure you want to leave this page?', // TODO translate
STAY: 'Stay', // TODO translate
LEAVE: 'Leave', // TODO translate
SCHEDULER: 'Scheduler', // TODO translate
SCHEDULER_HELP_1: 'Automate commands by adding scheduled events below. Set a unique Name to enable/disable activation via API/MQTT', // TODO translate
SCHEDULER_HELP_2: 'Use 00:00 to trigger once on start-up', // TODO translate
SCHEDULE: 'Schedule', // TODO translate
TIME: 'Time', // TODO translate
TIMER: 'Timer', // TODO translate
ONCHANGE: 'Sur le changement', // TODO translate
CONDITION: 'Condition', // TODO translate
IMMEDIATE: 'Immédiate', // TODO translate
SCHEDULE_UPDATED: 'Schedule updated', // TODO translate
SCHEDULE_TIMER_1: 'on startup', // TODO translate
SCHEDULE_TIMER_2: 'every minute', // TODO translate
SCHEDULE_TIMER_3: 'every hour', // TODO translate
CUSTOM_ENTITIES: 'Custom Entities', // TODO translate
ENTITIES_HELP_1: 'Fetch custom entities from the EMS bus', // TODO translate
ENTITIES_UPDATED: 'Entities Updated', // TODO translate
WRITEABLE: 'Writeable', // TODO translate
SHOWING: 'Showing', // TODO translate
SEARCH: 'Search', // TODO translate
CERT: 'TLS root certificate (leave blank for insecure)', // TODO translate
BLOCK_NAVIGATE_1: 'Vous avez des modifications non enregistrées',
BLOCK_NAVIGATE_2: 'Si vous naviguez vers une autre page, vos modifications non enregistrées seront perdues. Êtes-vous sûr de vouloir quitter cette page ?',
STAY: 'Rester',
LEAVE: 'Quitter',
SCHEDULER: 'Scheduler',
SCHEDULER_HELP_1: 'Automatiser les commandes en ajoutant des événements programmés ci-dessous. Définissez un nom unique pour activer/désactiver l\'activation via API/MQTT',
SCHEDULER_HELP_2: 'Utiliser 00:00 pour déclencher une fois au démarrage',
SCHEDULE: 'Programme',
TIME: 'Temps',
TIMER: 'Minuteur',
ONCHANGE: 'Sur le changement',
CONDITION: 'Condition',
IMMEDIATE: 'Immédiat',
SCHEDULE_UPDATED: 'Programme mis à jour',
SCHEDULE_TIMER_1: 'au démarrage',
SCHEDULE_TIMER_2: 'toutes les minutes',
SCHEDULE_TIMER_3: 'toutes les heures',
CUSTOM_ENTITIES: 'Entités personnalisées',
ENTITIES_HELP_1: 'Récupérer les entités personnalisées du bus EMS',
ENTITIES_UPDATED: 'Entités mises à jour',
WRITEABLE: 'Écriture',
SHOWING: 'Affichage',
SEARCH: 'Rechercher',
CERT: 'Certificat racine TLS (laisser vide pour l\'insecurité)',
ENABLE_TLS: 'Activer TLS',
ON: 'On', // TODO translate
OFF: 'Off', // TODO translate
POLARITY: 'Polarity', // TODO translate
ACTIVEHIGH: 'Active High', // TODO translate
ACTIVELOW: 'Active Low', // TODO translate
UNCHANGED: 'Unchanged', // TODO translate
ALWAYS: 'Always', // TODO translate
ACTIVITY: 'Activity', // TODO translate
CONFIGURE: 'Configure {0}', // TODO translate
SYSTEM_MEMORY: 'System Memory', // TODO translate
APPLICATION_SETTINGS_1: 'Modify EMS-ESP Application Settings', // TODO translate
SECURITY_1: 'Add or remove users', // TODO translate
DOWNLOAD_UPLOAD_1: 'Download and Upload Settings and Firmware', // TODO translate
MODULES: 'Module', // TODO translate
ON: 'On',
OFF: 'Off',
POLARITY: 'Polarity',
ACTIVEHIGH: 'Actif haut',
ACTIVELOW: 'Actif bas',
UNCHANGED: 'Inchangé',
ALWAYS: 'Toujours',
ACTIVITY: 'Activité',
CONFIGURE: 'Configurer {0}',
SYSTEM_MEMORY: 'Mémoire système',
APPLICATION_SETTINGS_1: 'Modifier les paramètres de l\'application EMS-ESP',
SECURITY_1: 'Ajouter ou supprimer des utilisateurs',
DOWNLOAD_UPLOAD_1: 'Télécharger et mettre à jour les paramètres et le firmware',
MODULES: 'Module',
MODULES_1: 'Activer ou désactiver les modules externes',
MODULES_UPDATED: 'Modules updated', // TODO translate
MODULES_DESCRIPTION: 'Click on the Module to activate or de-activate EMS-ESP library modules', // TODO translate
MODULES_NONE: 'No external modules detected', // TODO translate
RENAME: 'Rename', // TODO translate
MODULES_UPDATED: 'Modules mis à jour',
MODULES_DESCRIPTION: 'Cliquer sur le module pour activer ou désactiver les modules EMS-ESP',
MODULES_NONE: 'Aucun module externe détecté',
RENAME: 'Renommer',
ENABLE_MODBUS: 'Activer Modbus',
VIEW_LOG: 'View log to diagnose issues', // TODO translate
UPLOAD_DRAG: 'drag and drop a file here or click to select one', // TODO translate
SERVICES: 'Services', // TODO translate
ALLVALUES: 'All Values', // TODO translate
SPECIAL_FUNCTIONS: 'Special Functions',
WAIT_FIRMWARE: 'Firmware is uploading and installing', // TODO translate
INSTALL_VERSION: 'This will install version {0}. Are you sure?', // TODO translate
UPGRADE_AVAILABLE: 'There is a firmware upgrade available!', // TODO translate
LATEST_VERSION: 'You are using the latest firmware version', // TODO translate
PLEASE_WAIT: 'Please wait', // TODO translate
RESTARTING_PRE: 'Initializing', // TODO translate
RESTARTING_POST: 'Preparing', // TODO translate
AUTO_SCROLL: 'Auto Scroll', // TODO translate
DASHBOARD: 'Dashboard', // TODO translate
DEVELOPER_MODE: 'Developer Mode', // TODO translate
BYTES: 'Bytes', // TODO translate
BITMASK: 'Bit Mask',// TODO translate
DUPLICATE: 'Duplicate', // TODO translate
UPGRADE: 'Upgrade', // TODO translate
DASHBOARD_1: 'All EMS entities that are active and marked as Favorite, plus all Custom Entities, Schedules and external Sensors data are displayed below.', // TODO translate
NO_DATA_1: 'No favorite EMS entities found yet. Use the', // TODO translate
NO_DATA_2: 'module to mark them.', // TODO translate
NO_DATA_3: 'To see all available entities go to', // TODO translate
THIS_VERSION: 'This Version', // TODO translate
PLATFORM: 'Platform', // TODO translate
RELEASE_TYPE: 'Release Type', // TODO translate
REINSTALL: 'Re-install', // TODO translate
INTERNET_CONNECTION_REQUIRED: 'Internet connection required for automatic version checking and upgrading',
VIEW_LOG: 'Voir le journal pour diagnostiquer les problèmes',
UPLOAD_DRAG: 'glisser-déposer un fichier ici ou cliquer pour en sélectionner un',
SERVICES: 'Services',
ALLVALUES: 'Toutes les valeurs',
SPECIAL_FUNCTIONS: 'Fonctions spéciales',
WAIT_FIRMWARE: 'Firmware en cours de téléchargement et d\'installation',
INSTALL_VERSION: 'Cela va {0} la version {1}. Êtes-vous sûr ?',
UPDATE_AVAILABLE: 'mise à jour disponible',
LATEST_VERSION: 'Vous utilisez la dernière version {0} du firmware',
PLEASE_WAIT: 'Veuillez patienter',
RESTARTING_PRE: 'Démarrage',
RESTARTING_POST: 'Préparation',
AUTO_SCROLL: 'Défilement automatique',
DASHBOARD: 'Tableau de bord',
DEVELOPER_MODE: 'Mode développeur',
BYTES: 'Octets',
BITMASK: 'Masque de bits',
DUPLICATE: 'Dupliquer',
DASHBOARD_1: 'Toutes les entités EMS actives et marquées comme favoris, plus toutes les entités personnalisées, les programmes et les données des capteurs externes sont affichées ci-dessous.',
NO_DATA_1: 'Aucune entité EMS favorite trouvée. Utilisez le',
NO_DATA_2: 'module pour les marquer.',
NO_DATA_3: 'Pour voir toutes les entités disponibles, aller à',
THIS_VERSION: 'Cette version',
PLATFORM: 'Plateforme',
RELEASE_TYPE: 'Type de version',
INTERNET_CONNECTION_REQUIRED: 'Connexion Internet requise pour la vérification automatique des versions et la mise à niveau',
SWITCH_RELEASE_TYPE: 'Passer à la version {0}',
FIRMWARE_VERSION_INFO: 'Informations sur la version du firmware',
NO_DATA: 'Aucune donnée',
USER_PROFILE: 'Profil utilisateur',
STORED_VERSIONS: 'Versions stockées'
};
export default fr;

Some files were not shown because too many files have changed in this diff Show More