import { memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useState } from 'react'; import { IconContext } from 'react-icons'; import { useNavigate } from 'react-router'; import { toast } from 'react-toastify'; import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined'; import ConstructionIcon from '@mui/icons-material/Construction'; import EditIcon from '@mui/icons-material/Edit'; import EditOffOutlinedIcon from '@mui/icons-material/EditOffOutlined'; import DownloadIcon from '@mui/icons-material/GetApp'; import HighlightOffIcon from '@mui/icons-material/HighlightOff'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import KeyboardArrowDownOutlinedIcon from '@mui/icons-material/KeyboardArrowDownOutlined'; import KeyboardArrowUpOutlinedIcon from '@mui/icons-material/KeyboardArrowUpOutlined'; import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import SearchIcon from '@mui/icons-material/Search'; import StarIcon from '@mui/icons-material/Star'; import StarBorderOutlinedIcon from '@mui/icons-material/StarBorderOutlined'; import UnfoldMoreOutlinedIcon from '@mui/icons-material/UnfoldMoreOutlined'; import { Box, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, Grid, IconButton, InputAdornment, List, ListItem, ListItemText, TextField, ToggleButton, Typography } from '@mui/material'; import { useRowSelect } from '@table-library/react-table-library/select'; import { SortToggleType, useSort } from '@table-library/react-table-library/sort'; import { Body, Cell, Header, HeaderCell, HeaderRow, Row, Table } from '@table-library/react-table-library/table'; import { useTheme } from '@table-library/react-table-library/theme'; import type { Action, State } from '@table-library/react-table-library/types/common'; import { dialogStyle } from 'CustomTheme'; import { useRequest } from 'alova/client'; import { ButtonTooltip, MessageBox, SectionContent, useLayoutTitle } from 'components'; import { AuthenticatedContext } from 'contexts/authentication'; import { useI18nContext } from 'i18n/i18n-react'; import { useInterval } from 'utils'; import { readCoreData, readDeviceData, writeDeviceValue } from '../../api/app'; import DeviceIcon from './DeviceIcon'; import DevicesDialog from './DevicesDialog'; import { formatValue } from './deviceValue'; import { DeviceEntityMask, DeviceType, DeviceValueUOM_s } from './types'; import type { Device, DeviceValue } from './types'; import { deviceValueItemValidation } from './validators'; const Devices = memo(() => { const { LL } = useI18nContext(); const { me } = useContext(AuthenticatedContext); const [size, setSize] = useState([0, 0]); const [selectedDeviceValue, setSelectedDeviceValue] = useState(); const [onlyFav, setOnlyFav] = useState(false); const [deviceValueDialogOpen, setDeviceValueDialogOpen] = useState(false); const [showDeviceInfo, setShowDeviceInfo] = useState(false); const [selectedDevice, setSelectedDevice] = useState(); const [search, setSearch] = useState(''); const navigate = useNavigate(); useLayoutTitle(LL.DEVICES()); const { data: coreData, send: sendCoreData } = useRequest(() => readCoreData(), { initialData: { connected: true, devices: [] } }); const { data: deviceData, send: sendDeviceData } = useRequest( (id: number) => readDeviceData(id), { initialData: { nodes: [] }, immediate: false } ); const { loading: submitting, send: sendDeviceValue } = useRequest( (data: { id: number; c: string; v: unknown }) => writeDeviceValue(data), { immediate: false } ); useLayoutEffect(() => { function updateSize() { setSize([window.innerWidth, window.innerHeight]); } window.addEventListener('resize', updateSize); updateSize(); return () => window.removeEventListener('resize', updateSize); }, []); const leftOffset = () => { 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; } return left + (right - left < 400 ? 0 : 200); }; const common_theme = useMemo( () => useTheme({ BaseRow: ` font-size: 14px; `, HeaderRow: ` text-transform: uppercase; background-color: black; color: #90CAF9; .th { border-bottom: 1px solid #565656; } `, Row: ` cursor: pointer; background-color: #1E1E1E; .td { padding: 8px; } &.tr.tr-body.row-select.row-select-single-selected { background-color: #177ac9; } ` }), [] ); 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: ` .th { padding: 8px; `, Row: ` &:nth-of-type(odd) .td { background-color: #303030; }, &:hover .td { background-color: #177ac9; }, ` } ]), [common_theme] ); const data_theme = useMemo( () => useTheme([ common_theme, { Table: ` --data-table-library_grid-template-columns: minmax(200px, auto) minmax(150px, auto) 40px; height: auto; max-height: 100%; overflow-y: scroll; ::-webkit-scrollbar { display:none; } `, BaseRow: ` .td { height: 32px; } `, BaseCell: ` &:nth-of-type(1) { border-left: 1px solid #177ac9; }, &:nth-of-type(2) { text-align: right; }, &:nth-of-type(3) { border-right: 1px solid #177ac9; } `, HeaderRow: ` .th { border-top: 1px solid #565656; } `, Row: ` &:nth-of-type(odd) .td { background-color: #303030; }, &:hover .td { background-color: #177ac9; } ` } ]), [common_theme] ); const getSortIcon = (state: State, sortKey: unknown) => { if (state.sortKey === sortKey && state.reverse) { return ; } if (state.sortKey === sortKey && !state.reverse) { return ; } return ; }; const dv_sort = useSort( { nodes: deviceData.nodes }, {}, { sortIcon: { iconDefault: , iconUp: , iconDown: }, sortToggleType: SortToggleType.AlternateWithReset, sortFns: { NAME: (array) => array.sort((a, b) => a.id.toString().slice(2).localeCompare(b.id.toString().slice(2)) ), VALUE: (array) => // eslint-disable-next-line @typescript-eslint/no-unsafe-return array.sort((a, b) => a.v.toString().localeCompare(b.v.toString())) } } ); async function onSelectChange(action: Action, state: State) { setSelectedDevice(state.id as number); if (action.type === 'ADD_BY_ID_EXCLUSIVELY') { await sendDeviceData(state.id as number); } } const device_select = useRowSelect( { nodes: coreData.devices }, { onChange: onSelectChange } ); const resetDeviceSelect = () => { device_select.fns.onRemoveAll(); setSearch(''); }; const escFunction = useCallback( (event: KeyboardEvent) => { if (event.key === 'Escape') { if (device_select) { device_select.fns.onRemoveAll(); } } }, [device_select] ); useEffect(() => { document.addEventListener('keydown', escFunction); return () => { document.removeEventListener('keydown', escFunction); }; }, [escFunction]); const customize = () => { if (selectedDevice === 99) { void navigate('/customentities'); } else { void navigate('/customizations', { state: selectedDevice }); } }; const escapeCsvCell = (cell: string) => { if (cell == null) { return ''; } const sc = cell.toString().trim(); if (sc === '' || sc === '""') { return sc; } if ( sc.includes('"') || sc.includes(';') || sc.includes('\n') || sc.includes('\r') ) { return '"' + sc.replace(/"/g, '""') + '"'; } return sc; }; const hasMask = useCallback( (id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask, [] ); const handleDownloadCsv = () => { const deviceIndex = coreData.devices.findIndex( (d: Device) => d.id === device_select.state.id ); if (deviceIndex === -1) { return; } const selectedDevice = coreData.devices[deviceIndex]; if (!selectedDevice) { return; } const filename = selectedDevice.tn + '_' + selectedDevice.n; const columns = [ { accessor: (dv: DeviceValue) => dv.id.slice(2), name: LL.ENTITY_NAME(0) }, { accessor: (dv: DeviceValue) => typeof dv.v === 'number' ? new Intl.NumberFormat().format(dv.v) : dv.v, name: LL.VALUE(0) }, { accessor: (dv: DeviceValue) => dv.u !== undefined && DeviceValueUOM_s[dv.u] ? DeviceValueUOM_s[dv.u]?.replace(/[^a-zA-Z0-9]/g, '') : '', name: 'UoM' }, { accessor: (dv: DeviceValue) => dv.c && !hasMask(dv.id, DeviceEntityMask.DV_READONLY) ? 'yes' : 'no', name: LL.WRITEABLE() }, { accessor: (dv: DeviceValue) => dv.h ? dv.h : dv.l ? dv.l.join(' | ') : dv.m !== undefined && dv.x !== undefined ? dv.m + ', ' + dv.x : '', name: 'Range' } ]; const data = onlyFav ? deviceData.nodes.filter((dv: DeviceValue) => hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) ) : deviceData.nodes; const csvData = data.reduce( (csvString: string, rowItem: DeviceValue) => csvString + columns .map(({ accessor }: { accessor: (dv: DeviceValue) => unknown }) => escapeCsvCell(accessor(rowItem) as string) ) .join(';') + '\r\n', columns.map(({ name }: { name: string }) => escapeCsvCell(name)).join(';') + '\r\n' ); const downloadBlob = (blob: Blob) => { const downloadLink = document.createElement('a'); downloadLink.download = filename; downloadLink.href = window.URL.createObjectURL(blob); document.body.appendChild(downloadLink); downloadLink.click(); document.body.removeChild(downloadLink); }; const device = { ...{ device: coreData.devices[deviceIndex] }, ...deviceData }; downloadBlob( new Blob([JSON.stringify(device, null, 2)], { type: 'text;charset:utf-8' }) ); downloadBlob(new Blob([csvData], { type: 'text/csv;charset:utf-8' })); }; useInterval(() => { if (!deviceValueDialogOpen) { selectedDevice ? void sendDeviceData(selectedDevice) : void sendCoreData(); } }); const deviceValueDialogSave = async (devicevalue: DeviceValue) => { const id = Number(device_select.state.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(async () => { setDeviceValueDialogOpen(false); await sendDeviceData(id); setSelectedDeviceValue(undefined); }); }; const renderDeviceDetails = () => { if (showDeviceInfo) { const deviceIndex = coreData.devices.findIndex( (d: Device) => d.id === device_select.state.id ); if (deviceIndex === -1) { return null; } const deviceDetails = coreData.devices[deviceIndex]; if (!deviceDetails) { return null; } return ( setShowDeviceInfo(false)} > {LL.DEVICE_DETAILS()} {deviceDetails.t !== DeviceType.CUSTOM && ( <> )} ); } return null; }; const renderCoreData = () => ( <> {!coreData.connected && ( )} {coreData.connected && ( {(tableList: Device[]) => ( <>
{LL.DESCRIPTION()} {LL.TYPE(0)}
{tableList.length === 0 && ( )} {tableList.map((device: Device) => (    {device.n}   ({device.e}) {device.tn} ))} )}
)}
); const deviceValueDialogClose = () => { setDeviceValueDialogOpen(false); if (selectedDevice !== undefined) { void sendDeviceData(selectedDevice); } }; const renderDeviceData = () => { if (!selectedDevice) { return; } const showDeviceValue = useCallback((dv: DeviceValue) => { setSelectedDeviceValue(dv); setDeviceValueDialogOpen(true); }, []); const renderNameCell = useCallback( (dv: DeviceValue) => ( <> {dv.id.slice(2)}  {hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && ( )} {hasMask(dv.id, DeviceEntityMask.DV_READONLY) && ( )} {hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && ( )} ), [hasMask] ); const shown_data = useMemo(() => { if (onlyFav) { return deviceData.nodes.filter( (dv: DeviceValue) => hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && dv.id.slice(2).toLowerCase().includes(search.toLowerCase()) ); } return deviceData.nodes.filter((dv: DeviceValue) => dv.id.slice(2).toLowerCase().includes(search.toLowerCase()) ); }, [deviceData.nodes, onlyFav, search]); const deviceIndex = coreData.devices.findIndex( (d: Device) => d.id === device_select.state.id ); if (deviceIndex === -1) { return; } const deviceInfo = coreData.devices[deviceIndex]; if (!deviceInfo) { return; } const [, height] = size; return ( leftOffset(), right: 0, bottom: 0, top: 64, zIndex: 'modal', maxHeight: () => (height || 0) - 126, border: '1px solid #177ac9' }} > {deviceInfo.n} ( {deviceInfo.tn}) { setSearch(event.target.value); }} slotProps={{ input: { startAdornment: ( ) } }} /> setShowDeviceInfo(true)}> {me.admin && ( )} { setOnlyFav(!onlyFav); }} > {onlyFav ? ( ) : ( )}{' '}   {LL.SHOWING() + ' ' + shown_data.length + '/' + deviceInfo.e + ' ' + LL.ENTITIES(shown_data.length)} {(tableList: DeviceValue[]) => ( <>
{tableList.map((dv: DeviceValue) => ( showDeviceValue(dv)}> {renderNameCell(dv)} {formatValue(LL, dv.v, dv.u)} {me.admin && dv.c && !hasMask(dv.id, DeviceEntityMask.DV_READONLY) && ( showDeviceValue(dv)} > {dv.v === '' ? ( ) : ( )} )} ))} )}
); }; return ( {renderCoreData()} {renderDeviceData()} {renderDeviceDetails()} {selectedDeviceValue && ( )} ); }); export default Devices;